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 web: flask db upgrade; flask translate compile; gunicorn microblog:app
worker: rq worker microblog-tasks

View File

@ -1,3 +1,5 @@
# Welcome to Microblog! # 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. 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_moment import Moment
from flask_babel import Babel, lazy_gettext as _l from flask_babel import Babel, lazy_gettext as _l
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from redis import Redis
import rq
from config import Config from config import Config
@ -38,6 +40,8 @@ def create_app(config_class=Config):
babel.init_app(app, locale_selector=get_locale) babel.init_app(app, locale_selector=get_locale)
app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
if app.config['ELASTICSEARCH_URL'] else None if app.config['ELASTICSEARCH_URL'] else None
app.redis = Redis.from_url(app.config['REDIS_URL'])
app.task_queue = rq.Queue('microblog-tasks', connection=app.redis)
from app.errors import bp as errors_bp from app.errors import bp as errors_bp
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp)
@ -51,6 +55,9 @@ def create_app(config_class=Config):
from app.cli import bp as cli_bp from app.cli import bp as cli_bp
app.register_blueprint(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 not app.debug and not app.testing:
if app.config['MAIL_SERVER']: if app.config['MAIL_SERVER']:
auth = None auth = None

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

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

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) mail.send(msg)
def send_email(subject, sender, recipients, text_body, html_body): def send_email(subject, sender, recipients, text_body, html_body,
attachments=None, sync=False):
msg = Message(subject, sender=sender, recipients=recipients) msg = Message(subject, sender=sender, recipients=recipients)
msg.body = text_body msg.body = text_body
msg.html = html_body msg.html = html_body
if attachments:
for attachment in attachments:
msg.attach(*attachment)
if sync:
mail.send(msg)
else:
Thread(target=send_async_email, Thread(target=send_async_email,
args=(current_app._get_current_object(), msg)).start() args=(current_app._get_current_object(), msg)).start()

View File

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

View File

@ -45,3 +45,9 @@ class SearchForm(FlaskForm):
if 'meta' not in kwargs: if 'meta' not in kwargs:
kwargs['meta'] = {'csrf': False} kwargs['meta'] = {'csrf': False}
super(SearchForm, self).__init__(*args, **kwargs) 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 import sqlalchemy as sa
from langdetect import detect, LangDetectException from langdetect import detect, LangDetectException
from app import db from app import db
from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm, \
from app.models import User, Post MessageForm
from app.models import User, Post, Message, Notification
from app.translate import translate from app.translate import translate
from app.main import bp from app.main import bp
@ -175,3 +176,66 @@ def search():
if page > 1 else None if page > 1 else None
return render_template('search.html', title=_('Search'), posts=posts, return render_template('search.html', title=_('Search'), posts=posts,
next_url=next_url, prev_url=prev_url) 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 from hashlib import md5
import json
import secrets
from time import time from time import time
from typing import Optional from typing import Optional
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as so import sqlalchemy.orm as so
from flask import current_app from flask import current_app, url_for
from flask_login import UserMixin from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import jwt import jwt
import redis
import rq
from app import db, login from app import db, login
from app.search import add_to_index, remove_from_index, query_index from app.search import add_to_index, remove_from_index, query_index
@ -56,6 +60,31 @@ db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit)
db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit)
class PaginatedAPIMixin(object):
@staticmethod
def to_collection_dict(query, page, per_page, endpoint, **kwargs):
resources = 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 = sa.Table(
'followers', 'followers',
db.metadata, 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) id: so.Mapped[int] = so.mapped_column(primary_key=True)
username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True,
unique=True) unique=True)
@ -76,6 +105,10 @@ class User(UserMixin, db.Model):
about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140)) about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
last_seen: so.Mapped[Optional[datetime]] = so.mapped_column( last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(
default=lambda: datetime.now(timezone.utc)) 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( posts: so.WriteOnlyMapped['Post'] = so.relationship(
back_populates='author') back_populates='author')
@ -87,6 +120,13 @@ class User(UserMixin, db.Model):
secondary=followers, primaryjoin=(followers.c.followed_id == id), secondary=followers, primaryjoin=(followers.c.followed_id == id),
secondaryjoin=(followers.c.follower_id == id), secondaryjoin=(followers.c.follower_id == id),
back_populates='following') 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): def __repr__(self):
return '<User {}>'.format(self.username) return '<User {}>'.format(self.username)
@ -152,6 +192,92 @@ class User(UserMixin, db.Model):
return return
return db.session.get(User, id) 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 @login.user_loader
def load_user(id): def load_user(id):
@ -172,3 +298,59 @@ class Post(SearchableMixin, db.Model):
def __repr__(self): def __repr__(self):
return '<Post {}>'.format(self.body) 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> <a class="nav-link" aria-current="page" href="{{ url_for('auth.login') }}">{{ _('Login') }}</a>
</li> </li>
{% else %} {% 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"> <li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a> <a class="nav-link" aria-current="page" href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a>
</li> </li>
@ -55,6 +65,19 @@
</div> </div>
</nav> </nav>
<div class="container mt-3"> <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() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
@ -117,6 +140,42 @@
} }
} }
document.addEventListener('DOMContentLoaded', initialize_popovers); 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> </script>
</body> </body>
</html> </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> <p>{{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_count()) }}</p>
{% if user == current_user %} {% if user == current_user %}
<p><a href="{{ url_for('main.edit_profile') }}">{{ _('Edit your profile') }}</a></p> <p><a href="{{ url_for('main.edit_profile') }}">{{ _('Edit your profile') }}</a></p>
{% if not current_user.get_task_in_progress('export_posts') %}
<p><a href="{{ url_for('main.export_posts') }}">{{ _('Export your posts') }}</a></p>
{% endif %}
{% elif not current_user.is_following(user) %} {% elif not current_user.is_following(user) %}
<p> <p>
<form action="{{ url_for('main.follow', username=user.username) }}" method="post"> <form action="{{ url_for('main.follow', username=user.username) }}" method="post">
@ -28,6 +31,9 @@
</form> </form>
</p> </p>
{% endif %} {% endif %}
{% if user != current_user %}
<p><a href="{{ url_for('main.send_message', recipient=user.username) }}">{{ _('Send private message') }}</a></p>
{% endif %}
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-11-25 18:23-0800\n" "POT-Creation-Date: 2017-11-25 18:27-0800\n"
"PO-Revision-Date: 2017-09-29 23:25-0700\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es\n" "Language: es\n"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n" "Generated-By: Babel 2.5.1\n"
#: app/__init__.py:18 #: app/__init__.py:20
msgid "Please log in to access this page." msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página." msgstr "Por favor ingrese para acceder a esta página."
@ -94,7 +94,7 @@ msgstr "Tu contraseña ha sido cambiada."
msgid "About me" msgid "About me"
msgstr "Acerca de mí" 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" msgid "Submit"
msgstr "Enviar" msgstr "Enviar"
@ -106,47 +106,67 @@ msgstr "Dí algo"
msgid "Search" msgid "Search"
msgstr "Buscar" msgstr "Buscar"
#: app/main/forms.py:43
msgid "Message"
msgstr "Mensaje"
#: app/main/routes.py:36 #: app/main/routes.py:36
msgid "Your post is now live!" msgid "Your post is now live!"
msgstr "¡Tu artículo ha sido publicado!" msgstr "¡Tu artículo ha sido publicado!"
#: app/main/routes.py:87 #: app/main/routes.py:94
msgid "Your changes have been saved." msgid "Your changes have been saved."
msgstr "Tus cambios han sido salvados." 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" msgid "Edit Profile"
msgstr "Editar Perfil" 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 #, python-format
msgid "User %(username)s not found." msgid "User %(username)s not found."
msgstr "El usuario %(username)s no ha sido encontrado." msgstr "El usuario %(username)s no ha sido encontrado."
#: app/main/routes.py:104 #: app/main/routes.py:111
msgid "You cannot follow yourself!" msgid "You cannot follow yourself!"
msgstr "¡No te puedes seguir a tí mismo!" msgstr "¡No te puedes seguir a tí mismo!"
#: app/main/routes.py:108 #: app/main/routes.py:115
#, python-format #, python-format
msgid "You are following %(username)s!" msgid "You are following %(username)s!"
msgstr "¡Ahora estás siguiendo a %(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!" msgid "You cannot unfollow yourself!"
msgstr "¡No te puedes dejar de seguir a tí mismo!" msgstr "¡No te puedes dejar de seguir a tí mismo!"
#: app/main/routes.py:124 #: app/main/routes.py:131
#, python-format #, python-format
msgid "You are not following %(username)s." msgid "You are not following %(username)s."
msgstr "No estás siguiendo a %(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 #, python-format
msgid "%(username)s said %(when)s" msgid "%(username)s said %(when)s"
msgstr "%(username)s dijo %(when)s" msgstr "%(username)s dijo %(when)s"
#: app/templates/_post.html:25 #: app/templates/_post.html:27
msgid "Translate" msgid "Translate"
msgstr "Traducir" msgstr "Traducir"
@ -166,15 +186,19 @@ msgstr "Explorar"
msgid "Login" msgid "Login"
msgstr "Ingresar" 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" msgid "Profile"
msgstr "Perfil" msgstr "Perfil"
#: app/templates/base.html:36 #: app/templates/base.html:46
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: app/templates/base.html:73 #: app/templates/base.html:95
msgid "Error: Could not contact server." msgid "Error: Could not contact server."
msgstr "Error: el servidor no pudo ser contactado." msgstr "Error: el servidor no pudo ser contactado."
@ -183,40 +207,53 @@ msgstr "Error: el servidor no pudo ser contactado."
msgid "Hi, %(username)s!" msgid "Hi, %(username)s!"
msgstr "¡Hola, %(username)s!" msgstr "¡Hola, %(username)s!"
#: app/templates/index.html:17 app/templates/user.html:31 #: app/templates/index.html:17 app/templates/user.html:37
msgid "Newer posts" msgid "Newer posts"
msgstr "Artículos siguientes" msgstr "Artículos siguientes"
#: app/templates/index.html:22 app/templates/user.html:36 #: app/templates/index.html:22 app/templates/user.html:42
msgid "Older posts" msgid "Older posts"
msgstr "Artículos previos" 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 #: app/templates/search.html:4
msgid "Search Results" msgid "Search Results"
msgstr "Resultados de Búsqueda" msgstr ""
#: app/templates/search.html:12 #: app/templates/search.html:12
msgid "Previous results" msgid "Previous results"
msgstr "Resultados previos" msgstr ""
#: app/templates/search.html:17 #: app/templates/search.html:17
msgid "Next results" 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 #: app/templates/user.html:8
msgid "User" msgid "User"
msgstr "Usuario" msgstr "Usuario"
#: app/templates/user.html:11 #: app/templates/user.html:11 app/templates/user_popup.html:9
msgid "Last seen on" msgid "Last seen on"
msgstr "Última visita" msgstr "Última visita"
#: app/templates/user.html:13 #: app/templates/user.html:13 app/templates/user_popup.html:11
#, python-format #, python-format
msgid "%(count)d followers" msgid "%(count)d followers"
msgstr "%(count)d seguidores" msgstr "%(count)d seguidores"
#: app/templates/user.html:13 #: app/templates/user.html:13 app/templates/user_popup.html:11
#, python-format #, python-format
msgid "%(count)d following" msgid "%(count)d following"
msgstr "siguiendo a %(count)d" msgstr "siguiendo a %(count)d"
@ -226,13 +263,21 @@ msgid "Edit your profile"
msgstr "Editar tu perfil" msgstr "Editar tu perfil"
#: app/templates/user.html:17 #: app/templates/user.html:17
msgid "Export your posts"
msgstr "Exportar tus artículos"
#: app/templates/user.html:20 app/templates/user_popup.html:14
msgid "Follow" msgid "Follow"
msgstr "Seguir" msgstr "Seguir"
#: app/templates/user.html:19 #: app/templates/user.html:22 app/templates/user_popup.html:16
msgid "Unfollow" msgid "Unfollow"
msgstr "Dejar de seguir" msgstr "Dejar de seguir"
#: app/templates/user.html:25
msgid "Send private message"
msgstr "Enviar mensaje privado"
#: app/templates/auth/login.html:12 #: app/templates/auth/login.html:12
msgid "New User?" msgid "New User?"
msgstr "¿Usuario Nuevo?" msgstr "¿Usuario Nuevo?"

View File

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

View File

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

View File

@ -1,11 +1,12 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as so import sqlalchemy.orm as so
from app import create_app, db 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 = create_app()
@app.shell_context_processor @app.shell_context_processor
def make_shell_context(): 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 certifi==2023.11.17
charset-normalizer==3.3.2 charset-normalizer==3.3.2
click==8.1.7 click==8.1.7
defusedxml==0.7.1
dnspython==2.4.2 dnspython==2.4.2
elastic-transport==8.10.0 elastic-transport==8.10.0
elasticsearch==8.11.0 elasticsearch==8.11.0
email-validator==2.1.0.post1 email-validator==2.1.0.post1
Flask==3.0.0 Flask==3.0.0
flask-babel==4.0.0 flask-babel==4.0.0
Flask-HTTPAuth==4.8.0
Flask-Login==0.6.3 Flask-Login==0.6.3
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-Migrate==4.0.5 Flask-Migrate==4.0.5
@ -20,17 +22,29 @@ Flask-Moment==1.0.5
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1 Flask-WTF==1.2.1
greenlet==3.0.1 greenlet==3.0.1
gunicorn==21.2.0
httpie==3.2.2
idna==3.4 idna==3.4
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
langdetect==1.0.9 langdetect==1.0.9
Mako==1.3.0 Mako==1.3.0
markdown-it-py==3.0.0
MarkupSafe==2.1.3 MarkupSafe==2.1.3
mdurl==0.1.2
multidict==6.0.4
packaging==23.2 packaging==23.2
psycopg2-binary==2.9.9
Pygments==2.17.1
PyJWT==2.8.0 PyJWT==2.8.0
PySocks==1.7.1
python-dotenv==1.0.0 python-dotenv==1.0.0
pytz==2023.3.post1 pytz==2023.3.post1
redis==5.0.1
requests==2.31.0 requests==2.31.0
requests-toolbelt==1.0.0
rich==13.7.0
rq==1.15.1
setuptools==68.2.2 setuptools==68.2.2
six==1.16.0 six==1.16.0
SQLAlchemy==2.0.23 SQLAlchemy==2.0.23