Chapter 21: User Notifications (v0.21)

This commit is contained in:
Miguel Grinberg 2017-11-12 23:53:18 -08:00
parent 7f32336d21
commit a25d6f2f19
No known key found for this signature in database
GPG Key ID: 36848B262DF5F06C
11 changed files with 308 additions and 28 deletions

View File

@ -41,3 +41,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

@ -5,8 +5,9 @@ from flask_login import current_user, login_required
from flask_babel import _, get_locale from flask_babel import _, get_locale
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
@ -169,3 +170,52 @@ 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 = User.query.filter_by(username=recipient).first_or_404()
form = MessageForm()
if form.validate_on_submit():
msg = Message(author=current_user, recipient=user,
body=form.message.data)
db.session.add(msg)
user.add_notification('unread_message_count', user.new_messages())
db.session.commit()
flash(_('Your message has been sent.'))
return redirect(url_for('main.user', username=recipient))
return render_template('send_message.html', title=_('Send Message'),
form=form, recipient=recipient)
@bp.route('/messages')
@login_required
def messages():
current_user.last_message_read_time = datetime.utcnow()
current_user.add_notification('unread_message_count', 0)
db.session.commit()
page = request.args.get('page', 1, type=int)
messages = current_user.messages_received.order_by(
Message.timestamp.desc()).paginate(
page=page, per_page=current_app.config['POSTS_PER_PAGE'],
error_out=False)
next_url = url_for('main.messages', page=messages.next_num) \
if messages.has_next else None
prev_url = url_for('main.messages', page=messages.prev_num) \
if messages.has_prev else None
return render_template('messages.html', messages=messages.items,
next_url=next_url, prev_url=prev_url)
@bp.route('/notifications')
@login_required
def notifications():
since = request.args.get('since', 0.0, type=float)
notifications = current_user.notifications.filter(
Notification.timestamp > since).order_by(Notification.timestamp.asc())
return jsonify([{
'name': n.name,
'data': n.get_data(),
'timestamp': n.timestamp
} for n in notifications])

View File

@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
from hashlib import md5 from hashlib import md5
import json
from time import time from time import time
from flask import current_app from flask import current_app
from flask_login import UserMixin from flask_login import UserMixin
@ -72,6 +73,15 @@ class User(UserMixin, db.Model):
primaryjoin=(followers.c.follower_id == id), primaryjoin=(followers.c.follower_id == id),
secondaryjoin=(followers.c.followed_id == id), secondaryjoin=(followers.c.followed_id == id),
backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') backref=db.backref('followers', lazy='dynamic'), lazy='dynamic')
messages_sent = db.relationship('Message',
foreign_keys='Message.sender_id',
backref='author', lazy='dynamic')
messages_received = db.relationship('Message',
foreign_keys='Message.recipient_id',
backref='recipient', lazy='dynamic')
last_message_read_time = db.Column(db.DateTime)
notifications = db.relationship('Notification', backref='user',
lazy='dynamic')
def __repr__(self): def __repr__(self):
return '<User {}>'.format(self.username) return '<User {}>'.format(self.username)
@ -120,6 +130,17 @@ class User(UserMixin, db.Model):
return return
return User.query.get(id) return User.query.get(id)
def new_messages(self):
last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
return Message.query.filter_by(recipient=self).filter(
Message.timestamp > last_read_time).count()
def add_notification(self, name, data):
self.notifications.filter_by(name=name).delete()
n = Notification(name=name, payload_json=json.dumps(data), user=self)
db.session.add(n)
return n
@login.user_loader @login.user_loader
def load_user(id): def load_user(id):
@ -136,3 +157,25 @@ 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 = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'))
recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'))
body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
def __repr__(self):
return '<Message {}>'.format(self.body)
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), index=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
timestamp = db.Column(db.Float, index=True, default=time)
payload_json = db.Column(db.Text)
def get_data(self):
return json.loads(str(self.payload_json))

View File

@ -32,6 +32,16 @@
{% if current_user.is_anonymous %} {% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li> <li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li>
{% else %} {% else %}
<li>
<a href="{{ url_for('main.messages') }}">{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %}
<span id="message_count" class="badge"
style="visibility: {% if new_messages %}visible
{% else %}hidden{% endif %};">
{{ new_messages }}
</span>
</a>
</li>
<li><a href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a></li> <li><a href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a></li>
<li><a href="{{ url_for('auth.logout') }}">{{ _('Logout') }}</a></li> <li><a href="{{ url_for('auth.logout') }}">{{ _('Logout') }}</a></li>
{% endif %} {% endif %}
@ -115,5 +125,25 @@
} }
); );
}); });
function set_message_count(n) {
$('#message_count').text(n);
$('#message_count').css('visibility', n ? 'visible' : 'hidden');
}
{% if current_user.is_authenticated %}
$(function() {
var since = 0;
setInterval(function() {
$.ajax('{{ url_for('main.notifications') }}?since=' + since).done(
function(notifications) {
for (var i = 0; i < notifications.length; i++) {
if (notifications[i].name == 'unread_message_count')
set_message_count(notifications[i].data);
since = notifications[i].timestamp;
}
}
);
}, 10000);
});
{% endif %}
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -28,6 +28,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:26-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"
@ -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,59 @@ 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/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 +178,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:83
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 +199,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:34
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:39
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"
@ -225,14 +254,18 @@ msgstr "siguiendo a %(count)d"
msgid "Edit your profile" msgid "Edit your profile"
msgstr "Editar tu perfil" msgstr "Editar tu perfil"
#: app/templates/user.html:17 #: app/templates/user.html:17 app/templates/user_popup.html:14
msgid "Follow" msgid "Follow"
msgstr "Seguir" msgstr "Seguir"
#: app/templates/user.html:19 #: app/templates/user.html:19 app/templates/user_popup.html:16
msgid "Unfollow" msgid "Unfollow"
msgstr "Dejar de seguir" msgstr "Dejar de seguir"
#: app/templates/user.html:22
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

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

View File

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

View File

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