diff --git a/app/main/forms.py b/app/main/forms.py index dc28728..a7c8d96 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -41,3 +41,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')) diff --git a/app/main/routes.py b/app/main/routes.py index e004e9e..b829f15 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -5,8 +5,9 @@ from flask_login import current_user, login_required from flask_babel import _, get_locale from langdetect import detect, LangDetectException from app import db -from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm -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 @@ -169,3 +170,52 @@ 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/', 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]) diff --git a/app/models.py b/app/models.py index 161fb7c..35c3646 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from datetime import datetime from hashlib import md5 +import json from time import time from flask import current_app from flask_login import UserMixin @@ -72,6 +73,15 @@ class User(UserMixin, db.Model): primaryjoin=(followers.c.follower_id == id), secondaryjoin=(followers.c.followed_id == id), backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') + messages_sent = db.relationship('Message', + foreign_keys='Message.sender_id', + backref='author', lazy='dynamic') + messages_received = db.relationship('Message', + foreign_keys='Message.recipient_id', + backref='recipient', lazy='dynamic') + last_message_read_time = db.Column(db.DateTime) + notifications = db.relationship('Notification', backref='user', + lazy='dynamic') def __repr__(self): return ''.format(self.username) @@ -120,6 +130,17 @@ class User(UserMixin, db.Model): return return User.query.get(id) + def new_messages(self): + last_read_time = self.last_message_read_time or datetime(1900, 1, 1) + return Message.query.filter_by(recipient=self).filter( + Message.timestamp > last_read_time).count() + + def add_notification(self, name, data): + self.notifications.filter_by(name=name).delete() + n = Notification(name=name, payload_json=json.dumps(data), user=self) + db.session.add(n) + return n + @login.user_loader def load_user(id): @@ -136,3 +157,25 @@ class Post(SearchableMixin, db.Model): def __repr__(self): return ''.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 ''.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)) diff --git a/app/templates/base.html b/app/templates/base.html index b5f970f..b32f0ce 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -32,6 +32,16 @@ {% if current_user.is_anonymous %}
  • {{ _('Login') }}
  • {% else %} +
  • + {{ _('Messages') }} + {% set new_messages = current_user.new_messages() %} + + {{ new_messages }} + + +
  • {{ _('Profile') }}
  • {{ _('Logout') }}
  • {% 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 %} {% endblock %} diff --git a/app/templates/messages.html b/app/templates/messages.html new file mode 100644 index 0000000..fdfd047 --- /dev/null +++ b/app/templates/messages.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block app_content %} +

    {{ _('Messages') }}

    + {% for post in messages %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/send_message.html b/app/templates/send_message.html new file mode 100644 index 0000000..2987d6a --- /dev/null +++ b/app/templates/send_message.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

    {{ _('Send Message to %(recipient)s', recipient=recipient) }}

    +
    +
    + {{ wtf.quick_form(form) }} +
    +
    +{% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html index f264891..1e598b8 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -28,6 +28,9 @@

    {% endif %} + {% if user != current_user %} +

    {{ _('Send private message') }}

    + {% endif %} diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po index df667c9..dac4264 100644 --- a/app/translations/es/LC_MESSAGES/messages.po +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -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:26-0800\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -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,59 @@ 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/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 +178,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:83 msgid "Error: Could not contact server." msgstr "Error: el servidor no pudo ser contactado." @@ -183,40 +199,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:34 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:39 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" @@ -225,14 +254,18 @@ msgstr "siguiendo a %(count)d" msgid "Edit your profile" msgstr "Editar tu perfil" -#: app/templates/user.html:17 +#: app/templates/user.html:17 app/templates/user_popup.html:14 msgid "Follow" msgstr "Seguir" -#: app/templates/user.html:19 +#: app/templates/user.html:19 app/templates/user_popup.html:16 msgid "Unfollow" msgstr "Dejar de seguir" +#: app/templates/user.html:22 +msgid "Send private message" +msgstr "Enviar mensaje privado" + #: app/templates/auth/login.html:12 msgid "New User?" msgstr "¿Usuario Nuevo?" diff --git a/microblog.py b/microblog.py index 20d62e6..499da2a 100644 --- a/microblog.py +++ b/microblog.py @@ -1,5 +1,5 @@ from app import create_app, db, cli -from app.models import User, Post +from app.models import User, Post, Message, Notification app = create_app() cli.register(app) @@ -7,4 +7,5 @@ cli.register(app) @app.shell_context_processor def make_shell_context(): - return {'db': db, 'User': User, 'Post': Post} + return {'db': db, 'User': User, 'Post': Post, 'Message': Message, + 'Notification': Notification} diff --git a/migrations/versions/d049de007ccf_private_messages.py b/migrations/versions/d049de007ccf_private_messages.py new file mode 100644 index 0000000..c1f3be9 --- /dev/null +++ b/migrations/versions/d049de007ccf_private_messages.py @@ -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 ### diff --git a/migrations/versions/f7ac3d27bb1d_notifications.py b/migrations/versions/f7ac3d27bb1d_notifications.py new file mode 100644 index 0000000..9cc7b04 --- /dev/null +++ b/migrations/versions/f7ac3d27bb1d_notifications.py @@ -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 ###