Compare commits

...

4 Commits
v0.20 ... 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
29 changed files with 868 additions and 35 deletions

View File

@ -1 +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).

View File

@ -9,6 +9,8 @@ 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
@ -38,6 +40,8 @@ def create_app(config_class=Config):
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)
@ -51,6 +55,9 @@ def create_app(config_class=Config):
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

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

View File

@ -9,9 +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=(current_app._get_current_object(), msg)).start()
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,14 +1,24 @@
from flask import render_template
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

@ -45,3 +45,9 @@ class SearchForm(FlaskForm):
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'))

View File

@ -6,8 +6,9 @@ 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
from app.models import User, Post
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
@ -175,3 +176,66 @@ def search():
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,17 @@
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
from flask import current_app, url_for
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
import jwt
import redis
import rq
from app import db, login
from app.search import add_to_index, remove_from_index, query_index
@ -56,6 +60,31 @@ 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(
'followers',
db.metadata,
@ -66,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)
@ -76,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')
@ -87,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)
@ -152,6 +192,92 @@ class User(UserMixin, db.Model):
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):
@ -172,3 +298,59 @@ class Post(SearchableMixin, db.Model):
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

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

@ -43,6 +43,16 @@
<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('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('main.user', username=current_user.username) }}">{{ _('Profile') }}</a>
</li>
@ -55,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 %}
@ -117,6 +140,42 @@
}
}
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

@ -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 %}

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

@ -13,6 +13,9 @@
<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('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('main.follow', username=user.username) }}" method="post">
@ -28,6 +31,9 @@
</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

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-11-25 18:23-0800\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"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n"
#: app/__init__.py:18
#: app/__init__.py:20
msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página."
@ -94,7 +94,7 @@ msgstr "Tu contraseña ha sido cambiada."
msgid "About me"
msgstr "Acerca de mí"
#: app/main/forms.py:13 app/main/forms.py:28
#: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44
msgid "Submit"
msgstr "Enviar"
@ -106,47 +106,67 @@ msgstr "Dí algo"
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:87
#: app/main/routes.py:94
msgid "Your changes have been saved."
msgstr "Tus cambios han sido salvados."
#: app/main/routes.py:92 app/templates/edit_profile.html:5
#: app/main/routes.py:99 app/templates/edit_profile.html:5
msgid "Edit Profile"
msgstr "Editar Perfil"
#: app/main/routes.py:101 app/main/routes.py:117
#: 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:104
#: app/main/routes.py:111
msgid "You cannot follow yourself!"
msgstr "¡No te puedes seguir a tí mismo!"
#: app/main/routes.py:108
#: 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:120
#: app/main/routes.py:127
msgid "You cannot unfollow yourself!"
msgstr "¡No te puedes dejar de seguir a tí mismo!"
#: app/main/routes.py:124
#: app/main/routes.py:131
#, python-format
msgid "You are not following %(username)s."
msgstr "No estás siguiendo a %(username)s."
#: app/templates/_post.html:14
#: 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:25
#: app/templates/_post.html:27
msgid "Translate"
msgstr "Traducir"
@ -166,15 +186,19 @@ 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:73
#: app/templates/base.html:95
msgid "Error: Could not contact server."
msgstr "Error: el servidor no pudo ser contactado."
@ -183,40 +207,53 @@ msgstr "Error: el servidor no pudo ser contactado."
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/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 "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"
@ -226,13 +263,21 @@ 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?"

View File

@ -21,4 +21,5 @@ class Config:
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,9 @@
[program:microblog-tasks]
command=/home/ubuntu/microblog/venv/bin/rq worker microblog-tasks
numprocs=1
directory=/home/ubuntu/microblog
user=ubuntu
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true

View File

@ -1,11 +1,12 @@
import sqlalchemy as sa
import sqlalchemy.orm as so
from app import create_app, db
from app.models import User, Post
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 @@
"""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 ###

View File

@ -7,12 +7,14 @@ 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
@ -20,17 +22,29 @@ 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