Compare commits

...

11 Commits
v0.13 ... main

Author SHA1 Message Date
Miguel Grinberg
a975ef6486
Update README file 2025-04-06 20:01:21 +01:00
Miguel Grinberg
1cacb96692
Chapter 23: Application Programming Interfaces (APIs) (v0.23) 2025-04-06 20:01:21 +01:00
Miguel Grinberg
b11f0b62d6
Chapter 22: Background Jobs (v0.22) 2025-04-06 20:01:21 +01:00
Miguel Grinberg
88d52181dc
Chapter 21: User Notifications (v0.21) 2025-04-06 20:01:21 +01:00
Miguel Grinberg
6a4e5b0c01
Chapter 20: Some JavaScript Magic (v0.20) 2025-04-06 20:01:21 +01:00
Miguel Grinberg
c1943a1c4d
Chapter 19: Deployment on Docker Containers (v0.19) 2025-04-06 20:01:21 +01:00
Miguel Grinberg
f1b2b6f54c
Chapter 18: Deployment on Heroku (v0.18) 2025-04-06 20:01:18 +01:00
Miguel Grinberg
7f7c99a293
Chapter 17: Deployment on Linux (v0.17) 2025-04-06 20:00:44 +01:00
Miguel Grinberg
62584d3b03
Chapter 16: Full-Text Search (v0.16) 2024-07-30 23:44:03 +01:00
Miguel Grinberg
a1d4f2800e
Chapter 15: A Better Application Structure (v0.15) 2024-07-30 23:44:03 +01:00
Miguel Grinberg
b00c6902f1
Chapter 14: Ajax (v0.14) 2024-07-30 23:44:03 +01:00
59 changed files with 1787 additions and 434 deletions

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:slim
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
RUN 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 flask translate compile
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

View File

@ -1,3 +1,5 @@
# Welcome to Microblog!
This is an example application featured in my [Flask Mega-Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world). See the tutorial for instructions on how to work with it.
The version of the application featured in this repository corresponds to the 2024 edition of the Flask Mega-Tutorial. You can find the 2018 and 2021 versions of the code [here](https://github.com/miguelgrinberg/microblog-2018). And if for any strange reason you are interested in the original code, dating back to 2012, that is [here](https://github.com/miguelgrinberg/microblog-2012).

7
Vagrantfile vendored Normal file
View File

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

View File

@ -1,58 +1,99 @@
import logging
from logging.handlers import SMTPHandler, RotatingFileHandler
import os
from flask import Flask, request
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_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
def get_locale():
return request.accept_languages.best_match(app.config['LANGUAGES'])
return request.accept_languages.best_match(current_app.config['LANGUAGES'])
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login = LoginManager(app)
login.login_view = 'login'
db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()
login.login_view = 'auth.login'
login.login_message = _l('Please log in to access this page.')
mail = Mail(app)
moment = Moment(app)
babel = Babel(app, locale_selector=get_locale)
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)
app.logger.setLevel(logging.INFO)
app.logger.info('Microblog startup')
mail = Mail()
moment = Moment()
babel = Babel()
from app import routes, models, errors
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
mail.init_app(app)
moment.init_app(app)
babel.init_app(app, locale_selector=get_locale)
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.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.cli import bp as cli_bp
app.register_blueprint(cli_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
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

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

@ -0,0 +1,30 @@
import sqlalchemy as sa
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
from app import db
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 = db.session.scalar(sa.select(User).where(User.username == username))
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)

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

@ -0,0 +1,19 @@
from werkzeug.http import HTTP_STATUS_CODES
from werkzeug.exceptions import HTTPException
from app.api import bp
def error_response(status_code, message=None):
payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')}
if message:
payload['message'] = message
return payload, status_code
def bad_request(message):
return error_response(400, message)
@bp.errorhandler(HTTPException)
def handle_exception(e):
return error_response(e.code)

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

@ -0,0 +1,19 @@
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 {'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

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

@ -0,0 +1,81 @@
import sqlalchemy as sa
from flask import 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 db.get_or_404(User, 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)
return User.to_collection_dict(sa.select(User), page, per_page,
'api.get_users')
@bp.route('/users/<int:id>/followers', methods=['GET'])
@token_auth.login_required
def get_followers(id):
user = db.get_or_404(User, id)
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 100)
return User.to_collection_dict(user.followers.select(), page, per_page,
'api.get_followers', id=id)
@bp.route('/users/<int:id>/following', methods=['GET'])
@token_auth.login_required
def get_following(id):
user = db.get_or_404(User, id)
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 100)
return User.to_collection_dict(user.following.select(), page, per_page,
'api.get_following', id=id)
@bp.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
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 db.session.scalar(sa.select(User).where(
User.username == data['username'])):
return bad_request('please use a different username')
if db.session.scalar(sa.select(User).where(
User.email == data['email'])):
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()
return user.to_dict(), 201, {'Location': url_for('api.get_user',
id=user.id)}
@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 = db.get_or_404(User, id)
data = request.get_json()
if 'username' in data and data['username'] != user.username and \
db.session.scalar(sa.select(User).where(
User.username == data['username'])):
return bad_request('please use a different username')
if 'email' in data and data['email'] != user.email and \
db.session.scalar(sa.select(User).where(
User.email == data['email'])):
return bad_request('please use a different email address')
user.from_dict(data, new_user=False)
db.session.commit()
return 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))

View File

@ -1,9 +1,7 @@
from flask_wtf import FlaskForm
from flask_babel import _, lazy_gettext as _l
from wtforms import StringField, PasswordField, BooleanField, SubmitField, \
TextAreaField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, \
Length
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
import sqlalchemy as sa
from app import db
from app.models import User
@ -49,31 +47,3 @@ class ResetPasswordForm(FlaskForm):
_l('Repeat Password'), validators=[DataRequired(),
EqualTo('password')])
submit = SubmitField(_l('Request Password Reset'))
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().__init__(*args, **kwargs)
self.original_username = original_username
def validate_username(self, username):
if username.data != self.original_username:
user = db.session.scalar(sa.select(User).where(
User.username == username.data))
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(), Length(min=1, max=140)])
submit = SubmitField(_l('Submit'))

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

@ -0,0 +1,85 @@
from flask import render_template, redirect, url_for, flash, request
from urllib.parse import urlsplit
from flask_login import login_user, logout_user, current_user
from flask_babel import _
import sqlalchemy as sa
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 = db.session.scalar(
sa.select(User).where(User.username == form.username.data))
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 urlsplit(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 = db.session.scalar(
sa.select(User).where(User.email == form.email.data))
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)

View File

@ -1,9 +1,11 @@
import os
from flask import Blueprint
import click
from app import app
bp = Blueprint('cli', __name__, cli_group=None)
@app.cli.group()
@bp.cli.group()
def translate():
"""Translation and localization commands."""
pass

View File

@ -1,8 +1,7 @@
from threading import Thread
from flask import render_template
from flask import current_app
from flask_mail import Message
from flask_babel import _
from app import app, mail
from app import mail
def send_async_email(app, msg):
@ -10,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

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

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

@ -0,0 +1,53 @@
from flask import request
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import ValidationError, DataRequired, Length
import sqlalchemy as sa
from flask_babel import _, lazy_gettext as _l
from app import db
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().__init__(*args, **kwargs)
self.original_username = original_username
def validate_username(self, username):
if username.data != self.original_username:
user = db.session.scalar(sa.select(User).where(
User.username == username.data))
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(), Length(min=1, max=140)])
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'))

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

@ -0,0 +1,241 @@
from datetime import datetime, timezone
from flask import render_template, flash, redirect, url_for, request, g, \
current_app
from flask_login import current_user, login_required
from flask_babel import _, get_locale
import sqlalchemy as sa
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.now(timezone.utc)
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 = db.paginate(current_user.following_posts(), 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)
query = sa.select(Post).order_by(Post.timestamp.desc())
posts = db.paginate(query, 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 = db.first_or_404(sa.select(User).where(User.username == username))
page = request.args.get('page', 1, type=int)
query = user.posts.select().order_by(Post.timestamp.desc())
posts = db.paginate(query, 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 = db.first_or_404(sa.select(User).where(User.username == username))
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 = db.session.scalar(
sa.select(User).where(User.username == username))
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 = db.session.scalar(
sa.select(User).where(User.username == username))
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():
data = request.get_json()
return {'text': translate(data['text'],
data['source_language'],
data['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 = db.first_or_404(sa.select(User).where(User.username == recipient))
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.unread_message_count())
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.now(timezone.utc)
current_user.add_notification('unread_message_count', 0)
db.session.commit()
page = request.args.get('page', 1, type=int)
query = current_user.messages_received.select().order_by(
Message.timestamp.desc())
messages = db.paginate(query, 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)
query = current_user.notifications.select().where(
Notification.timestamp > since).order_by(Notification.timestamp.asc())
notifications = db.session.scalars(query)
return [{
'name': n.name,
'data': n.get_data(),
'timestamp': n.timestamp
} for n in notifications]

View File

@ -1,13 +1,88 @@
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from hashlib import md5
import json
import secrets
from time import time
from typing import Optional
import sqlalchemy as sa
import sqlalchemy.orm as so
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:
@classmethod
def search(cls, expression, page, per_page):
ids, total = query_index(cls.__tablename__, expression, page, per_page)
if total == 0:
return [], 0
when = []
for i in range(len(ids)):
when.append((ids[i], i))
query = sa.select(cls).where(cls.id.in_(ids)).order_by(
db.case(*when, value=cls.id))
return db.session.scalars(query), 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 db.session.scalars(sa.select(cls)):
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 = db.paginate(query, 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 = sa.Table(
@ -20,7 +95,7 @@ followers = sa.Table(
)
class User(UserMixin, db.Model):
class User(PaginatedAPIMixin, UserMixin, db.Model):
id: so.Mapped[int] = so.mapped_column(primary_key=True)
username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True,
unique=True)
@ -30,6 +105,10 @@ class User(UserMixin, db.Model):
about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(
default=lambda: datetime.now(timezone.utc))
last_message_read_time: so.Mapped[Optional[datetime]]
token: so.Mapped[Optional[str]] = so.mapped_column(
sa.String(32), index=True, unique=True)
token_expiration: so.Mapped[Optional[datetime]]
posts: so.WriteOnlyMapped['Post'] = so.relationship(
back_populates='author')
@ -41,6 +120,13 @@ class User(UserMixin, db.Model):
secondary=followers, primaryjoin=(followers.c.followed_id == id),
secondaryjoin=(followers.c.follower_id == id),
back_populates='following')
messages_sent: so.WriteOnlyMapped['Message'] = so.relationship(
foreign_keys='Message.sender_id', back_populates='author')
messages_received: so.WriteOnlyMapped['Message'] = so.relationship(
foreign_keys='Message.recipient_id', back_populates='recipient')
notifications: so.WriteOnlyMapped['Notification'] = so.relationship(
back_populates='user')
tasks: so.WriteOnlyMapped['Task'] = so.relationship(back_populates='user')
def __repr__(self):
return '<User {}>'.format(self.username)
@ -95,32 +181,176 @@ 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 Exception:
return
return db.session.get(User, id)
def unread_message_count(self):
last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
query = sa.select(Message).where(Message.recipient == self,
Message.timestamp > last_read_time)
return db.session.scalar(sa.select(sa.func.count()).select_from(
query.subquery()))
def add_notification(self, name, data):
db.session.execute(self.notifications.delete().where(
Notification.name == name))
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(f'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):
query = self.tasks.select().where(Task.complete == False)
return db.session.scalars(query)
def get_task_in_progress(self, name):
query = self.tasks.select().where(Task.name == name,
Task.complete == False)
return db.session.scalar(query)
def posts_count(self):
query = sa.select(sa.func.count()).select_from(
self.posts.select().subquery())
return db.session.scalar(query)
def to_dict(self, include_email=False):
data = {
'id': self.id,
'username': self.username,
'last_seen': self.last_seen.replace(
tzinfo=timezone.utc).isoformat(),
'about_me': self.about_me,
'post_count': self.posts_count(),
'follower_count': self.followers_count(),
'following_count': self.following_count(),
'_links': {
'self': url_for('api.get_user', id=self.id),
'followers': url_for('api.get_followers', id=self.id),
'following': url_for('api.get_following', 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.now(timezone.utc)
if self.token and self.token_expiration.replace(
tzinfo=timezone.utc) > now + timedelta(seconds=60):
return self.token
self.token = secrets.token_hex(16)
self.token_expiration = now + timedelta(seconds=expires_in)
db.session.add(self)
return self.token
def revoke_token(self):
self.token_expiration = datetime.now(timezone.utc) - timedelta(
seconds=1)
@staticmethod
def check_token(token):
user = db.session.scalar(sa.select(User).where(User.token == token))
if user is None or user.token_expiration.replace(
tzinfo=timezone.utc) < datetime.now(timezone.utc):
return None
return user
@login.user_loader
def load_user(id):
return db.session.get(User, int(id))
class Post(db.Model):
class Post(SearchableMixin, db.Model):
__searchable__ = ['body']
id: so.Mapped[int] = so.mapped_column(primary_key=True)
body: so.Mapped[str] = so.mapped_column(sa.String(140))
timestamp: so.Mapped[datetime] = so.mapped_column(
index=True, default=lambda: datetime.now(timezone.utc))
user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
index=True)
language: so.Mapped[Optional[str]] = so.mapped_column(sa.String(5))
author: so.Mapped[User] = so.relationship(back_populates='posts')
def __repr__(self):
return '<Post {}>'.format(self.body)
class Message(db.Model):
id: so.Mapped[int] = so.mapped_column(primary_key=True)
sender_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
index=True)
recipient_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
index=True)
body: so.Mapped[str] = so.mapped_column(sa.String(140))
timestamp: so.Mapped[datetime] = so.mapped_column(
index=True, default=lambda: datetime.now(timezone.utc))
author: so.Mapped[User] = so.relationship(
foreign_keys='Message.sender_id',
back_populates='messages_sent')
recipient: so.Mapped[User] = so.relationship(
foreign_keys='Message.recipient_id',
back_populates='messages_received')
def __repr__(self):
return '<Message {}>'.format(self.body)
class Notification(db.Model):
id: so.Mapped[int] = so.mapped_column(primary_key=True)
name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True)
user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
index=True)
timestamp: so.Mapped[float] = so.mapped_column(index=True, default=time)
payload_json: so.Mapped[str] = so.mapped_column(sa.Text)
user: so.Mapped[User] = so.relationship(back_populates='notifications')
def get_data(self):
return json.loads(str(self.payload_json))
class Task(db.Model):
id: so.Mapped[str] = so.mapped_column(sa.String(36), primary_key=True)
name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True)
description: so.Mapped[Optional[str]] = so.mapped_column(sa.String(128))
user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id))
complete: so.Mapped[bool] = so.mapped_column(default=False)
user: so.Mapped[User] = so.relationship(back_populates='tasks')
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,208 +0,0 @@
from datetime import datetime, timezone
from urllib.parse import urlsplit
from flask import render_template, flash, redirect, url_for, request, g
from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _, get_locale
import sqlalchemy as sa
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.now(timezone.utc)
db.session.commit()
g.locale = str(get_locale())
@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 = db.paginate(current_user.following_posts(), 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)
query = sa.select(Post).order_by(Post.timestamp.desc())
posts = db.paginate(query, 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 = db.session.scalar(
sa.select(User).where(User.username == form.username.data))
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 urlsplit(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 = db.session.scalar(
sa.select(User).where(User.email == form.email.data))
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 = db.first_or_404(sa.select(User).where(User.username == username))
page = request.args.get('page', 1, type=int)
query = user.posts.select().order_by(Post.timestamp.desc())
posts = db.paginate(query, 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 = db.session.scalar(
sa.select(User).where(User.username == username))
if user is None:
flash(_('User %(username)s not found.', username=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 %(username)s!', username=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 = db.session.scalar(
sa.select(User).where(User.username == username))
if user is None:
flash(_('User %(username)s not found.', username=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 %(username)s.', username=username))
return redirect(url_for('user', username=username))
else:
return redirect(url_for('index'))

28
app/search.py Normal file
View File

@ -0,0 +1,28 @@
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, document=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,
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

56
app/tasks.py Normal file
View File

@ -0,0 +1,56 @@
import json
import sys
import time
import sqlalchemy as sa
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 = db.session.get(Task, 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 = db.session.get(User, user_id)
_set_task_progress(0)
data = []
i = 0
total_posts = db.session.scalar(sa.select(sa.func.count()).select_from(
user.posts.select().subquery()))
for post in db.session.scalars(user.posts.select().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 Exception:
_set_task_progress(100)
app.logger.error('Unhandled exception', exc_info=sys.exc_info())
finally:
_set_task_progress(100)

View File

@ -1,20 +1,30 @@
<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>
{% set user_link %}
<a href="{{ url_for('user', username=post.author.username) }}">
<a class="user_popup" href="{{ url_for('main.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
{% 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

@ -4,9 +4,9 @@
{% block content %}
<h1>{{ _('Sign In') }}</h1>
{{ wtf.quick_form(form) }}
<p>{{ _('New User?') }} <a href="{{ url_for('register') }}">{{ _('Click to Register!') }}</a></p>
<p>{{ _('New User?') }} <a href="{{ url_for('auth.register') }}">{{ _('Click to Register!') }}</a></p>
<p>
{{ _('Forgot Your Password?') }}
<a href="{{ url_for('reset_password_request') }}">{{ _('Click to Reset It') }}</a>
<a href="{{ url_for('auth.reset_password_request') }}">{{ _('Click to Reset It') }}</a>
</p>
{% endblock %}

View File

@ -17,30 +17,47 @@
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">Microblog</a>
<a class="navbar-brand" href="{{ url_for('main.index') }}">Microblog</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('index') }}">{{ _('Home') }}</a>
<a class="nav-link" aria-current="page" href="{{ url_for('main.index') }}">{{ _('Home') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('explore') }}">{{ _('Explore') }}</a>
<a class="nav-link" aria-current="page" href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a>
</li>
{% 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>
<ul class="navbar-nav mb-2 mb-lg-0">
{% if current_user.is_anonymous %}
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('login') }}">{{ _('Login') }}</a>
<a class="nav-link" aria-current="page" href="{{ url_for('auth.login') }}">{{ _('Login') }}</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('user', username=current_user.username) }}">{{ _('Profile') }}</a>
<a class="nav-link" aria-current="page" href="{{ url_for('main.messages') }}">{{ _('Messages') }}
{% set unread_message_count = current_user.unread_message_count() %}
<span id="message_count" class="badge text-bg-danger"
style="visibility: {% if unread_message_count %}visible
{% else %}hidden{% endif %};">
{{ unread_message_count }}
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('logout') }}">{{ _('Logout') }}</a>
<a class="nav-link" aria-current="page" href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('auth.logout') }}">{{ _('Logout') }}</a>
</li>
{% endif %}
</ul>
@ -48,6 +65,19 @@
</div>
</nav>
<div class="container mt-3">
{% 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 %}
@ -64,5 +94,88 @@
</script>
{{ moment.include_moment() }}
{{ moment.lang(g.locale) }}
<script>
async function translate(sourceElem, destElem, sourceLang, destLang) {
document.getElementById(destElem).innerHTML =
'<img src="{{ url_for('static', filename='loading.gif') }}">';
const response = await fetch('/translate', {
method: 'POST',
headers: {'Content-Type': 'application/json; charset=utf-8'},
body: JSON.stringify({
text: document.getElementById(sourceElem).innerText,
source_language: sourceLang,
dest_language: destLang
})
})
const data = await response.json();
document.getElementById(destElem).innerText = data.text;
}
function initialize_popovers() {
const popups = document.getElementsByClassName('user_popup');
for (let i = 0; i < popups.length; i++) {
const popover = new bootstrap.Popover(popups[i], {
content: 'Loading...',
trigger: 'hover focus',
placement: 'right',
html: true,
sanitize: false,
delay: {show: 500, hide: 0},
container: popups[i],
customClass: 'd-inline',
});
popups[i].addEventListener('show.bs.popover', async (ev) => {
if (ev.target.popupLoaded) {
return;
}
const response = await fetch('/user/' + ev.target.innerText.trim() + '/popup');
const data = await response.text();
const popover = bootstrap.Popover.getInstance(ev.target);
if (popover && data) {
ev.target.popupLoaded = true;
popover.setContent({'.popover-body': data});
flask_moment_render_all();
}
});
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
function set_message_count(n) {
const count = document.getElementById('message_count');
count.innerText = n;
count.style.visibility = n ? 'visible' : 'hidden';
}
function set_task_progress(task_id, progress) {
const progressElement = document.getElementById(task_id + '-progress');
if (progressElement) {
progressElement.innerText = progress;
}
}
{% if current_user.is_authenticated %}
function initialize_notifications() {
let since = 0;
setInterval(async function() {
const response = await fetch('{{ url_for('main.notifications') }}?since=' + since);
const notifications = await response.json();
for (let 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);
}
document.addEventListener('DOMContentLoaded', initialize_notifications);
{% endif %}
</script>
</body>
</html>

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

@ -4,12 +4,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

@ -2,5 +2,5 @@
{% block content %}
<h1>{{ _('Not Found') }}</h1>
<p><a href="{{ url_for('index') }}">{{ _('Back') }}</a></p>
<p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p>
{% endblock %}

View File

@ -3,5 +3,5 @@
{% block 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>
<p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ _('Messages') }}</h1>
{% for post in messages %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="Post navigation">
<ul class="pagination">
<li class="page-item{% if not prev_url %} disabled{% endif %}">
<a class="page-link" href="#">
<span aria-hidden="true">&larr;</span> {{ _('Newer messages') }}
</a>
</li>
<li class="page-item{% if not next_url %} disabled{% endif %}">
<a class="page-link" href="#">
{{ _('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 content %}
<h1>{{ _('Search Results') }}</h1>
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="Post navigation">
<ul class="pagination">
<li class="page-item{% if not prev_url %} disabled{% endif %}">
<a class="page-link" href="{{ prev_url }}">
<span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
</a>
</li>
<li class="page-item{% if not next_url %} disabled{% endif %}">
<a class="page-link" href="{{ next_url }}">
{{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</nav>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% import "bootstrap_wtf.html" as wtf %}
{% block content %}
<h1>{{ _('Send Message to %(recipient)s', recipient=recipient) }}</h1>
{{ wtf.quick_form(form) }}
{% endblock %}

View File

@ -12,22 +12,28 @@
{% endif %}
<p>{{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_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-primary') }}
</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-primary') }}
</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>

View File

@ -0,0 +1,27 @@
<div>
<img src="{{ user.avatar(64) }}" style="margin: 5px; float: left">
<p><a href="{{ url_for('main.user', username=user.username) }}">{{ user.username }}</a></p>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
<div class="clearfix"></div>
{% 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.following_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-outline-primary 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-outline-primary btn-sm') }}
</form>
</p>
{% endif %}
{% endif %}
</div>

21
app/translate.py Normal file
View File

@ -0,0 +1,21 @@
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': 'westus'
}
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

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-10-03 15:49-0700\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"
@ -22,138 +22,154 @@ msgstr ""
msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página."
#: app/email.py:21
#: 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/forms.py:12 app/forms.py:19 app/forms.py:50
#: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10
msgid "Username"
msgstr "Nombre de usuario"
#: app/forms.py:13 app/forms.py:21 app/forms.py:43
#: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42
msgid "Password"
msgstr "Contraseña"
#: app/forms.py:14
#: app/auth/forms.py:12
msgid "Remember Me"
msgstr "Recordarme"
#: app/forms.py:15 app/templates/login.html:5
#: app/auth/forms.py:13 app/templates/auth/login.html:5
msgid "Sign In"
msgstr "Ingresar"
#: app/forms.py:20 app/forms.py:38
#: app/auth/forms.py:18 app/auth/forms.py:37
msgid "Email"
msgstr "Email"
#: app/forms.py:23 app/forms.py:45
#: app/auth/forms.py:21 app/auth/forms.py:44
msgid "Repeat Password"
msgstr "Repetir Contraseña"
#: app/forms.py:24 app/templates/register.html:5
#: app/auth/forms.py:23 app/templates/auth/register.html:5
msgid "Register"
msgstr "Registrarse"
#: app/forms.py:29 app/forms.py:62
#: 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/forms.py:34
#: app/auth/forms.py:33
msgid "Please use a different email address."
msgstr "Por favor use una dirección de email diferente."
#: app/forms.py:39 app/forms.py:46
#: app/auth/forms.py:38 app/auth/forms.py:46
msgid "Request Password Reset"
msgstr "Pedir una nueva contraseña"
#: app/forms.py:51
msgid "About me"
msgstr "Acerca de mí"
#: app/forms.py:52 app/forms.py:67
msgid "Submit"
msgstr "Enviar"
#: app/forms.py:66
msgid "Say something"
msgstr "Dí algo"
#: app/forms.py:71
msgid "Search"
msgstr "Buscar"
#: app/routes.py:30
msgid "Your post is now live!"
msgstr "¡Tu artículo ha sido publicado!"
#: app/routes.py:66
#: app/auth/routes.py:20
msgid "Invalid username or password"
msgstr "Nombre de usuario o contraseña inválidos"
#: app/routes.py:92
#: app/auth/routes.py:46
msgid "Congratulations, you are now a registered user!"
msgstr "¡Felicitaciones, ya eres un usuario registrado!"
#: app/routes.py:107
#: 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/routes.py:124
#: app/auth/routes.py:78
msgid "Your password has been reset."
msgstr "Tu contraseña ha sido cambiada."
#: app/routes.py:152
#: 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/routes.py:157 app/templates/edit_profile.html:5
#: app/main/routes.py:99 app/templates/edit_profile.html:5
msgid "Edit Profile"
msgstr "Editar Perfil"
#: app/routes.py:166 app/routes.py:182
#: 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/routes.py:169
#: app/main/routes.py:111
msgid "You cannot follow yourself!"
msgstr "¡No te puedes seguir a tí mismo!"
#: app/routes.py:173
#: app/main/routes.py:115
#, python-format
msgid "You are following %(username)s!"
msgstr "¡Ahora estás siguiendo a %(username)s!"
#: app/routes.py:185
#: app/main/routes.py:127
msgid "You cannot unfollow yourself!"
msgstr "¡No te puedes dejar de seguir a tí mismo!"
#: app/routes.py:189
#: app/main/routes.py:131
#, python-format
msgid "You are not following %(username)s."
msgstr "No estás siguiendo a %(username)s."
#: app/templates/404.html:4
msgid "Not Found"
msgstr "Página No Encontrada"
#: app/main/routes.py:170
msgid "Your message has been sent."
msgstr "Tu mensaje ha sido enviado."
#: app/templates/404.html:5 app/templates/500.html:6
msgid "Back"
msgstr "Atrás"
#: app/main/routes.py:172
msgid "Send Message"
msgstr "Enviar Mensaje"
#: app/templates/500.html:4
msgid "An unexpected error has occurred"
msgstr "Ha ocurrido un error inesperado"
#: app/main/routes.py:197
msgid "An export task is currently in progress"
msgstr "Una tarea de exportación esta en progreso"
#: app/templates/500.html:5
msgid "The administrator has been notified. Sorry for the inconvenience!"
msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!"
#: app/main/routes.py:199
msgid "Exporting posts..."
msgstr "Exportando artículos..."
#: app/templates/_post.html:9
#: 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"
@ -170,77 +186,74 @@ msgstr "Explorar"
msgid "Login"
msgstr "Ingresar"
#: app/templates/base.html:35
#: 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:36
#: 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:31
#: 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:36
#: app/templates/index.html:22 app/templates/user.html:42
msgid "Older posts"
msgstr "Artículos previos"
#: app/templates/login.html:12
msgid "New User?"
msgstr "¿Usuario Nuevo?"
#: app/templates/messages.html:12
msgid "Newer messages"
msgstr "Mensajes siguientes"
#: app/templates/login.html:12
msgid "Click to Register!"
msgstr "¡Haz click aquí para registrarte!"
#: app/templates/login.html:14
msgid "Forgot Your Password?"
msgstr "¿Te olvidaste tu contraseña?"
#: app/templates/login.html:15
msgid "Click to Reset It"
msgstr "Haz click aquí para pedir una nueva"
#: app/templates/reset_password.html:5
msgid "Reset Your Password"
msgstr "Nueva Contraseña"
#: app/templates/reset_password_request.html:5
msgid "Reset Password"
msgstr "Nueva Contraseña"
#: app/templates/messages.html:17
msgid "Older messages"
msgstr "Mensajes previos"
#: app/templates/search.html:4
msgid "Search Results"
msgstr "Resultados de Búsqueda"
msgstr ""
#: app/templates/search.html:12
msgid "Previous results"
msgstr "Resultados previos"
msgstr ""
#: app/templates/search.html:17
msgid "Next results"
msgstr "Resultados próximos"
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.html:11 app/templates/user_popup.html:9
msgid "Last seen on"
msgstr "Última visita"
#: app/templates/user.html:13
#: 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.html:13 app/templates/user_popup.html:11
#, python-format
msgid "%(count)d following"
msgstr "siguiendo a %(count)d"
@ -250,10 +263,58 @@ 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:19
#: 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!"

11
boot.sh Executable file
View File

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

View File

@ -1,11 +1,17 @@
import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
SERVER_NAME = os.environ.get('SERVER_NAME')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace(
'postgres://', 'postgresql://') or \
'sqlite:///' + os.path.join(basedir, 'app.db')
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
@ -13,4 +19,7 @@ class Config:
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,9 +1,12 @@
import sqlalchemy as sa
import sqlalchemy.orm as so
from app import app, db, cli
from app.models import User, Post
from app import create_app, db
from app.models import User, Post, Message, Notification, Task
app = create_app()
@app.shell_context_processor
def make_shell_context():
return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post}
return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post,
'Message': Message, 'Notification': Notification, 'Task': Task}

View File

@ -0,0 +1,32 @@
"""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! ###
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.add_column(sa.Column('language', sa.String(length=5), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_column('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=False),
sa.Column('description', sa.String(length=128), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('complete', sa.Boolean(), nullable=False),
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,53 @@
"""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=False),
sa.Column('recipient_id', sa.Integer(), nullable=False),
sa.Column('body', sa.String(length=140), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_message_recipient_id'), ['recipient_id'], unique=False)
batch_op.create_index(batch_op.f('ix_message_sender_id'), ['sender_id'], unique=False)
batch_op.create_index(batch_op.f('ix_message_timestamp'), ['timestamp'], unique=False)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('last_message_read_time', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('last_message_read_time')
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_message_timestamp'))
batch_op.drop_index(batch_op.f('ix_message_sender_id'))
batch_op.drop_index(batch_op.f('ix_message_recipient_id'))
op.drop_table('message')
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""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=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.Float(), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_notification_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_notification_timestamp'), ['timestamp'], unique=False)
batch_op.create_index(batch_op.f('ix_notification_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_notification_user_id'))
batch_op.drop_index(batch_op.f('ix_notification_timestamp'))
batch_op.drop_index(batch_op.f('ix_notification_name'))
op.drop_table('notification')
# ### end Alembic commands ###

54
requirements.txt Normal file
View File

@ -0,0 +1,54 @@
aiosmtpd==1.4.4.post2
alembic==1.12.1
atpublic==4.0
attrs==23.1.0
Babel==2.13.1
blinker==1.7.0
certifi==2023.11.17
charset-normalizer==3.3.2
click==8.1.7
defusedxml==0.7.1
dnspython==2.4.2
elastic-transport==8.10.0
elasticsearch==8.11.0
email-validator==2.1.0.post1
Flask==3.0.0
flask-babel==4.0.0
Flask-HTTPAuth==4.8.0
Flask-Login==0.6.3
Flask-Mail==0.9.1
Flask-Migrate==4.0.5
Flask-Moment==1.0.5
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1
greenlet==3.0.1
gunicorn==21.2.0
httpie==3.2.2
idna==3.4
itsdangerous==2.1.2
Jinja2==3.1.2
langdetect==1.0.9
Mako==1.3.0
markdown-it-py==3.0.0
MarkupSafe==2.1.3
mdurl==0.1.2
multidict==6.0.4
packaging==23.2
psycopg2-binary==2.9.9
Pygments==2.17.1
PyJWT==2.8.0
PySocks==1.7.1
python-dotenv==1.0.0
pytz==2023.3.post1
redis==5.0.1
requests==2.31.0
requests-toolbelt==1.0.0
rich==13.7.0
rq==1.15.1
setuptools==68.2.2
six==1.16.0
SQLAlchemy==2.0.23
typing_extensions==4.8.0
urllib3==2.1.0
Werkzeug==3.0.1
WTForms==3.1.1

View File

@ -1,15 +1,21 @@
import os
os.environ['DATABASE_URL'] = 'sqlite://'
#!/usr/bin/env python
from datetime import datetime, timezone, 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()