Compare commits
	
		
			8 Commits
		
	
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 3472187155 | |
|  | bb2273349f | |
|  | a25d6f2f19 | |
|  | 7f32336d21 | |
|  | b577aa6b62 | |
|  | 5cfb75def5 | |
|  | 94b40cf4b7 | |
|  | 868ad4b410 | 
|  | @ -0,0 +1,23 @@ | ||||||
|  | FROM python:slim | ||||||
|  | 
 | ||||||
|  | RUN useradd microblog | ||||||
|  | 
 | ||||||
|  | WORKDIR /home/microblog | ||||||
|  | 
 | ||||||
|  | COPY requirements.txt requirements.txt | ||||||
|  | RUN python -m venv venv | ||||||
|  | RUN venv/bin/pip install -r requirements.txt | ||||||
|  | RUN venv/bin/pip install gunicorn pymysql cryptography | ||||||
|  | 
 | ||||||
|  | COPY app app | ||||||
|  | COPY migrations migrations | ||||||
|  | COPY microblog.py config.py boot.sh ./ | ||||||
|  | RUN chmod a+x boot.sh | ||||||
|  | 
 | ||||||
|  | ENV FLASK_APP microblog.py | ||||||
|  | 
 | ||||||
|  | RUN chown -R microblog:microblog ./ | ||||||
|  | USER microblog | ||||||
|  | 
 | ||||||
|  | EXPOSE 5000 | ||||||
|  | ENTRYPOINT ["./boot.sh"] | ||||||
|  | @ -0,0 +1,2 @@ | ||||||
|  | web: flask db upgrade; flask translate compile; gunicorn microblog:app | ||||||
|  | worker: rq worker microblog-tasks | ||||||
|  | @ -0,0 +1,7 @@ | ||||||
|  | Vagrant.configure("2") do |config| | ||||||
|  |   config.vm.box = "ubuntu/focal64" | ||||||
|  |   config.vm.network "private_network", ip: "192.168.33.10" | ||||||
|  |   config.vm.provider "virtualbox" do |vb| | ||||||
|  |     vb.memory = "2048" | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -9,6 +9,9 @@ from flask_mail import Mail | ||||||
| from flask_bootstrap import Bootstrap | from flask_bootstrap import Bootstrap | ||||||
| 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 redis import Redis | ||||||
|  | import rq | ||||||
| from config import Config | from config import Config | ||||||
| 
 | 
 | ||||||
| db = SQLAlchemy() | db = SQLAlchemy() | ||||||
|  | @ -33,6 +36,10 @@ def create_app(config_class=Config): | ||||||
|     bootstrap.init_app(app) |     bootstrap.init_app(app) | ||||||
|     moment.init_app(app) |     moment.init_app(app) | ||||||
|     babel.init_app(app) |     babel.init_app(app) | ||||||
|  |     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 |     from app.errors import bp as errors_bp | ||||||
|     app.register_blueprint(errors_bp) |     app.register_blueprint(errors_bp) | ||||||
|  | @ -43,6 +50,9 @@ def create_app(config_class=Config): | ||||||
|     from app.main import bp as main_bp |     from app.main import bp as main_bp | ||||||
|     app.register_blueprint(main_bp) |     app.register_blueprint(main_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 | ||||||
|  | @ -60,6 +70,11 @@ def create_app(config_class=Config): | ||||||
|             mail_handler.setLevel(logging.ERROR) |             mail_handler.setLevel(logging.ERROR) | ||||||
|             app.logger.addHandler(mail_handler) |             app.logger.addHandler(mail_handler) | ||||||
| 
 | 
 | ||||||
|  |         if app.config['LOG_TO_STDOUT']: | ||||||
|  |             stream_handler = logging.StreamHandler() | ||||||
|  |             stream_handler.setLevel(logging.INFO) | ||||||
|  |             app.logger.addHandler(stream_handler) | ||||||
|  |         else: | ||||||
|             if not os.path.exists('logs'): |             if not os.path.exists('logs'): | ||||||
|                 os.mkdir('logs') |                 os.mkdir('logs') | ||||||
|             file_handler = RotatingFileHandler('logs/microblog.log', |             file_handler = RotatingFileHandler('logs/microblog.log', | ||||||
|  |  | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | from flask import Blueprint | ||||||
|  | 
 | ||||||
|  | bp = Blueprint('api', __name__) | ||||||
|  | 
 | ||||||
|  | from app.api import users, errors, tokens | ||||||
|  | @ -0,0 +1,28 @@ | ||||||
|  | from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth | ||||||
|  | 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 = User.query.filter_by(username=username).first() | ||||||
|  |     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) | ||||||
|  | @ -0,0 +1,15 @@ | ||||||
|  | from flask import jsonify | ||||||
|  | from werkzeug.http import HTTP_STATUS_CODES | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def error_response(status_code, message=None): | ||||||
|  |     payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} | ||||||
|  |     if message: | ||||||
|  |         payload['message'] = message | ||||||
|  |     response = jsonify(payload) | ||||||
|  |     response.status_code = status_code | ||||||
|  |     return response | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def bad_request(message): | ||||||
|  |     return error_response(400, message) | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | from flask import jsonify | ||||||
|  | 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 jsonify({'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 | ||||||
|  | @ -0,0 +1,80 @@ | ||||||
|  | from flask import jsonify, 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 jsonify(User.query.get_or_404(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) | ||||||
|  |     data = User.to_collection_dict(User.query, page, per_page, 'api.get_users') | ||||||
|  |     return jsonify(data) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @bp.route('/users/<int:id>/followers', methods=['GET']) | ||||||
|  | @token_auth.login_required | ||||||
|  | def get_followers(id): | ||||||
|  |     user = User.query.get_or_404(id) | ||||||
|  |     page = request.args.get('page', 1, type=int) | ||||||
|  |     per_page = min(request.args.get('per_page', 10, type=int), 100) | ||||||
|  |     data = User.to_collection_dict(user.followers, page, per_page, | ||||||
|  |                                    'api.get_followers', id=id) | ||||||
|  |     return jsonify(data) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @bp.route('/users/<int:id>/followed', methods=['GET']) | ||||||
|  | @token_auth.login_required | ||||||
|  | def get_followed(id): | ||||||
|  |     user = User.query.get_or_404(id) | ||||||
|  |     page = request.args.get('page', 1, type=int) | ||||||
|  |     per_page = min(request.args.get('per_page', 10, type=int), 100) | ||||||
|  |     data = User.to_collection_dict(user.followed, page, per_page, | ||||||
|  |                                    'api.get_followed', id=id) | ||||||
|  |     return jsonify(data) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @bp.route('/users', methods=['POST']) | ||||||
|  | def create_user(): | ||||||
|  |     data = request.get_json() or {} | ||||||
|  |     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 User.query.filter_by(username=data['username']).first(): | ||||||
|  |         return bad_request('please use a different username') | ||||||
|  |     if User.query.filter_by(email=data['email']).first(): | ||||||
|  |         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() | ||||||
|  |     response = jsonify(user.to_dict()) | ||||||
|  |     response.status_code = 201 | ||||||
|  |     response.headers['Location'] = url_for('api.get_user', id=user.id) | ||||||
|  |     return response | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @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 = User.query.get_or_404(id) | ||||||
|  |     data = request.get_json() or {} | ||||||
|  |     if 'username' in data and data['username'] != user.username and \ | ||||||
|  |             User.query.filter_by(username=data['username']).first(): | ||||||
|  |         return bad_request('please use a different username') | ||||||
|  |     if 'email' in data and data['email'] != user.email and \ | ||||||
|  |             User.query.filter_by(email=data['email']).first(): | ||||||
|  |         return bad_request('please use a different email address') | ||||||
|  |     user.from_dict(data, new_user=False) | ||||||
|  |     db.session.commit() | ||||||
|  |     return jsonify(user.to_dict()) | ||||||
|  | @ -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() | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -31,3 +31,19 @@ class PostForm(FlaskForm): | ||||||
|     post = TextAreaField(_l('Say something'), validators=[DataRequired()]) |     post = TextAreaField(_l('Say something'), validators=[DataRequired()]) | ||||||
|     submit = SubmitField(_l('Submit')) |     submit = SubmitField(_l('Submit')) | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | class SearchForm(FlaskForm): | ||||||
|  |     q = StringField(_l('Search'), validators=[DataRequired()]) | ||||||
|  | 
 | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         if 'formdata' not in kwargs: | ||||||
|  |             kwargs['formdata'] = request.args | ||||||
|  |         if 'meta' not in kwargs: | ||||||
|  |             kwargs['meta'] = {'csrf': False} | ||||||
|  |         super(SearchForm, self).__init__(*args, **kwargs) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MessageForm(FlaskForm): | ||||||
|  |     message = TextAreaField(_l('Message'), validators=[ | ||||||
|  |         DataRequired(), Length(min=1, max=140)]) | ||||||
|  |     submit = SubmitField(_l('Submit')) | ||||||
|  |  | ||||||
|  | @ -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 | 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 | ||||||
| 
 | 
 | ||||||
|  | @ -16,6 +17,7 @@ def before_request(): | ||||||
|     if current_user.is_authenticated: |     if current_user.is_authenticated: | ||||||
|         current_user.last_seen = datetime.utcnow() |         current_user.last_seen = datetime.utcnow() | ||||||
|         db.session.commit() |         db.session.commit() | ||||||
|  |         g.search_form = SearchForm() | ||||||
|     g.locale = str(get_locale()) |     g.locale = str(get_locale()) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -81,6 +83,14 @@ def user(username): | ||||||
|                            next_url=next_url, prev_url=prev_url, form=form) |                            next_url=next_url, prev_url=prev_url, form=form) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @bp.route('/user/<username>/popup') | ||||||
|  | @login_required | ||||||
|  | def user_popup(username): | ||||||
|  |     user = User.query.filter_by(username=username).first_or_404() | ||||||
|  |     form = EmptyForm() | ||||||
|  |     return render_template('user_popup.html', user=user, form=form) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @bp.route('/edit_profile', methods=['GET', 'POST']) | @bp.route('/edit_profile', methods=['GET', 'POST']) | ||||||
| @login_required | @login_required | ||||||
| def edit_profile(): | def edit_profile(): | ||||||
|  | @ -144,3 +154,79 @@ def translate_text(): | ||||||
|     return jsonify({'text': translate(request.form['text'], |     return jsonify({'text': translate(request.form['text'], | ||||||
|                                       request.form['source_language'], |                                       request.form['source_language'], | ||||||
|                                       request.form['dest_language'])}) |                                       request.form['dest_language'])}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @bp.route('/search') | ||||||
|  | @login_required | ||||||
|  | def search(): | ||||||
|  |     if not g.search_form.validate(): | ||||||
|  |         return redirect(url_for('main.explore')) | ||||||
|  |     page = request.args.get('page', 1, type=int) | ||||||
|  |     posts, total = Post.search(g.search_form.q.data, page, | ||||||
|  |                                current_app.config['POSTS_PER_PAGE']) | ||||||
|  |     next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \ | ||||||
|  |         if total > page * current_app.config['POSTS_PER_PAGE'] else None | ||||||
|  |     prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \ | ||||||
|  |         if page > 1 else None | ||||||
|  |     return render_template('search.html', title=_('Search'), posts=posts, | ||||||
|  |                            next_url=next_url, prev_url=prev_url) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @bp.route('/send_message/<recipient>', methods=['GET', 'POST']) | ||||||
|  | @login_required | ||||||
|  | def send_message(recipient): | ||||||
|  |     user = 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('/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) | ||||||
|  |     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]) | ||||||
|  |  | ||||||
							
								
								
									
										208
									
								
								app/models.py
								
								
								
								
							
							
						
						
									
										208
									
								
								app/models.py
								
								
								
								
							|  | @ -1,11 +1,85 @@ | ||||||
| from datetime import datetime | import base64 | ||||||
|  | from datetime import datetime, timedelta | ||||||
| from hashlib import md5 | from hashlib import md5 | ||||||
|  | import json | ||||||
|  | import os | ||||||
| from time import time | from time import time | ||||||
| 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 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SearchableMixin(object): | ||||||
|  |     @classmethod | ||||||
|  |     def search(cls, expression, page, per_page): | ||||||
|  |         ids, total = query_index(cls.__tablename__, expression, page, per_page) | ||||||
|  |         if total == 0: | ||||||
|  |             return cls.query.filter_by(id=0), 0 | ||||||
|  |         when = [] | ||||||
|  |         for i in range(len(ids)): | ||||||
|  |             when.append((ids[i], i)) | ||||||
|  |         return cls.query.filter(cls.id.in_(ids)).order_by( | ||||||
|  |             db.case(when, value=cls.id)), total | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def before_commit(cls, session): | ||||||
|  |         session._changes = { | ||||||
|  |             'add': list(session.new), | ||||||
|  |             'update': list(session.dirty), | ||||||
|  |             'delete': list(session.deleted) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def after_commit(cls, session): | ||||||
|  |         for obj in session._changes['add']: | ||||||
|  |             if isinstance(obj, SearchableMixin): | ||||||
|  |                 add_to_index(obj.__tablename__, obj) | ||||||
|  |         for obj in session._changes['update']: | ||||||
|  |             if isinstance(obj, SearchableMixin): | ||||||
|  |                 add_to_index(obj.__tablename__, obj) | ||||||
|  |         for obj in session._changes['delete']: | ||||||
|  |             if isinstance(obj, SearchableMixin): | ||||||
|  |                 remove_from_index(obj.__tablename__, obj) | ||||||
|  |         session._changes = None | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def reindex(cls): | ||||||
|  |         for obj in cls.query: | ||||||
|  |             add_to_index(cls.__tablename__, obj) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) | ||||||
|  | db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PaginatedAPIMixin(object): | ||||||
|  |     @staticmethod | ||||||
|  |     def to_collection_dict(query, page, per_page, endpoint, **kwargs): | ||||||
|  |         resources = query.paginate(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 = db.Table( | followers = db.Table( | ||||||
|  | @ -15,7 +89,7 @@ followers = db.Table( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class User(UserMixin, db.Model): | class User(UserMixin, PaginatedAPIMixin, db.Model): | ||||||
|     id = db.Column(db.Integer, primary_key=True) |     id = db.Column(db.Integer, primary_key=True) | ||||||
|     username = db.Column(db.String(64), index=True, unique=True) |     username = db.Column(db.String(64), index=True, unique=True) | ||||||
|     email = db.Column(db.String(120), index=True, unique=True) |     email = db.Column(db.String(120), index=True, unique=True) | ||||||
|  | @ -23,11 +97,23 @@ class User(UserMixin, db.Model): | ||||||
|     posts = db.relationship('Post', backref='author', lazy='dynamic') |     posts = db.relationship('Post', backref='author', lazy='dynamic') | ||||||
|     about_me = db.Column(db.String(140)) |     about_me = db.Column(db.String(140)) | ||||||
|     last_seen = db.Column(db.DateTime, default=datetime.utcnow) |     last_seen = db.Column(db.DateTime, default=datetime.utcnow) | ||||||
|  |     token = db.Column(db.String(32), index=True, unique=True) | ||||||
|  |     token_expiration = db.Column(db.DateTime) | ||||||
|     followed = db.relationship( |     followed = db.relationship( | ||||||
|         'User', secondary=followers, |         'User', secondary=followers, | ||||||
|         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') | ||||||
|  |     tasks = db.relationship('Task', backref='user', lazy='dynamic') | ||||||
| 
 | 
 | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return '<User {}>'.format(self.username) |         return '<User {}>'.format(self.username) | ||||||
|  | @ -76,13 +162,86 @@ 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 | ||||||
|  | 
 | ||||||
|  |     def launch_task(self, name, description, *args, **kwargs): | ||||||
|  |         rq_job = current_app.task_queue.enqueue('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): | ||||||
|  |         return Task.query.filter_by(user=self, complete=False).all() | ||||||
|  | 
 | ||||||
|  |     def get_task_in_progress(self, name): | ||||||
|  |         return Task.query.filter_by(name=name, user=self, | ||||||
|  |                                     complete=False).first() | ||||||
|  | 
 | ||||||
|  |     def to_dict(self, include_email=False): | ||||||
|  |         data = { | ||||||
|  |             'id': self.id, | ||||||
|  |             'username': self.username, | ||||||
|  |             'last_seen': self.last_seen.isoformat() + 'Z', | ||||||
|  |             'about_me': self.about_me, | ||||||
|  |             'post_count': self.posts.count(), | ||||||
|  |             'follower_count': self.followers.count(), | ||||||
|  |             'followed_count': self.followed.count(), | ||||||
|  |             '_links': { | ||||||
|  |                 'self': url_for('api.get_user', id=self.id), | ||||||
|  |                 'followers': url_for('api.get_followers', id=self.id), | ||||||
|  |                 'followed': url_for('api.get_followed', 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.utcnow() | ||||||
|  |         if self.token and self.token_expiration > now + timedelta(seconds=60): | ||||||
|  |             return self.token | ||||||
|  |         self.token = base64.b64encode(os.urandom(24)).decode('utf-8') | ||||||
|  |         self.token_expiration = now + timedelta(seconds=expires_in) | ||||||
|  |         db.session.add(self) | ||||||
|  |         return self.token | ||||||
|  | 
 | ||||||
|  |     def revoke_token(self): | ||||||
|  |         self.token_expiration = datetime.utcnow() - timedelta(seconds=1) | ||||||
|  | 
 | ||||||
|  |     @staticmethod | ||||||
|  |     def check_token(token): | ||||||
|  |         user = User.query.filter_by(token=token).first() | ||||||
|  |         if user is None or user.token_expiration < datetime.utcnow(): | ||||||
|  |             return None | ||||||
|  |         return user | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @login.user_loader | @login.user_loader | ||||||
| def load_user(id): | def load_user(id): | ||||||
|     return User.query.get(int(id)) |     return User.query.get(int(id)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Post(db.Model): | class Post(SearchableMixin, db.Model): | ||||||
|  |     __searchable__ = ['body'] | ||||||
|     id = db.Column(db.Integer, primary_key=True) |     id = db.Column(db.Integer, primary_key=True) | ||||||
|     body = db.Column(db.String(140)) |     body = db.Column(db.String(140)) | ||||||
|     timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) |     timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) | ||||||
|  | @ -91,3 +250,44 @@ class Post(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)) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Task(db.Model): | ||||||
|  |     id = db.Column(db.String(36), primary_key=True) | ||||||
|  |     name = db.Column(db.String(128), index=True) | ||||||
|  |     description = db.Column(db.String(128)) | ||||||
|  |     user_id = db.Column(db.Integer, db.ForeignKey('user.id')) | ||||||
|  |     complete = db.Column(db.Boolean, default=False) | ||||||
|  | 
 | ||||||
|  |     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 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,27 @@ | ||||||
|  | from flask import current_app | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def add_to_index(index, model): | ||||||
|  |     if not current_app.elasticsearch: | ||||||
|  |         return | ||||||
|  |     payload = {} | ||||||
|  |     for field in model.__searchable__: | ||||||
|  |         payload[field] = getattr(model, field) | ||||||
|  |     current_app.elasticsearch.index(index=index, id=model.id, body=payload) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def remove_from_index(index, model): | ||||||
|  |     if not current_app.elasticsearch: | ||||||
|  |         return | ||||||
|  |     current_app.elasticsearch.delete(index=index, id=model.id) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def query_index(index, query, page, per_page): | ||||||
|  |     if not current_app.elasticsearch: | ||||||
|  |         return [], 0 | ||||||
|  |     search = current_app.elasticsearch.search( | ||||||
|  |         index=index, | ||||||
|  |         body={'query': {'multi_match': {'query': query, 'fields': ['*']}}, | ||||||
|  |               'from': (page - 1) * per_page, 'size': per_page}) | ||||||
|  |     ids = [int(hit['_id']) for hit in search['hits']['hits']] | ||||||
|  |     return ids, search['hits']['total']['value'] | ||||||
|  | @ -0,0 +1,51 @@ | ||||||
|  | import json | ||||||
|  | import sys | ||||||
|  | import time | ||||||
|  | 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 = Task.query.get(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 = User.query.get(user_id) | ||||||
|  |         _set_task_progress(0) | ||||||
|  |         data = [] | ||||||
|  |         i = 0 | ||||||
|  |         total_posts = user.posts.count() | ||||||
|  |         for post in user.posts.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: | ||||||
|  |         _set_task_progress(100) | ||||||
|  |         app.logger.error('Unhandled exception', exc_info=sys.exc_info()) | ||||||
|  | @ -7,9 +7,11 @@ | ||||||
|             </td> |             </td> | ||||||
|             <td> |             <td> | ||||||
|                 {% set user_link %} |                 {% set user_link %} | ||||||
|  |                     <span class="user_popup"> | ||||||
|                         <a href="{{ url_for('main.user', username=post.author.username) }}"> |                         <a href="{{ url_for('main.user', username=post.author.username) }}"> | ||||||
|                             {{ post.author.username }} |                             {{ post.author.username }} | ||||||
|                         </a> |                         </a> | ||||||
|  |                     </span> | ||||||
|                 {% endset %} |                 {% endset %} | ||||||
|                 {{ _('%(username)s said %(when)s', |                 {{ _('%(username)s said %(when)s', | ||||||
|                     username=user_link, when=moment(post.timestamp).fromNow()) }} |                     username=user_link, when=moment(post.timestamp).fromNow()) }} | ||||||
|  |  | ||||||
|  | @ -21,10 +21,27 @@ | ||||||
|                     <li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li> |                     <li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li> | ||||||
|                     <li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li> |                     <li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li> | ||||||
|                 </ul> |                 </ul> | ||||||
|  |                 {% if g.search_form %} | ||||||
|  |                 <form class="navbar-form navbar-left" method="get" action="{{ url_for('main.search') }}"> | ||||||
|  |                     <div class="form-group"> | ||||||
|  |                         {{ g.search_form.q(size=20, class='form-control', placeholder=g.search_form.q.label.text) }} | ||||||
|  |                     </div> | ||||||
|  |                 </form> | ||||||
|  |                 {% endif %} | ||||||
|                 <ul class="nav navbar-nav navbar-right"> |                 <ul class="nav navbar-nav navbar-right"> | ||||||
|                     {% 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 %} | ||||||
|  | @ -36,6 +53,18 @@ | ||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
|     <div class="container"> |     <div class="container"> | ||||||
|  |         {% 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 %} | ||||||
|  | @ -66,5 +95,77 @@ | ||||||
|                 $(destElem).text("{{ _('Error: Could not contact server.') }}"); |                 $(destElem).text("{{ _('Error: Could not contact server.') }}"); | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|  |         $(function () { | ||||||
|  |             var timer = null; | ||||||
|  |             var xhr = null; | ||||||
|  |             $('.user_popup').hover( | ||||||
|  |                 function(event) { | ||||||
|  |                     // mouse in event handler | ||||||
|  |                     var elem = $(event.currentTarget); | ||||||
|  |                     timer = setTimeout(function() { | ||||||
|  |                         timer = null; | ||||||
|  |                         xhr = $.ajax( | ||||||
|  |                             '/user/' + elem.first().text().trim() + '/popup').done( | ||||||
|  |                                 function(data) { | ||||||
|  |                                     xhr = null; | ||||||
|  |                                     elem.popover({ | ||||||
|  |                                         trigger: 'manual', | ||||||
|  |                                         html: true, | ||||||
|  |                                         animation: false, | ||||||
|  |                                         container: elem, | ||||||
|  |                                         content: data | ||||||
|  |                                     }).popover('show'); | ||||||
|  |                                     flask_moment_render_all(); | ||||||
|  |                                 } | ||||||
|  |                             ); | ||||||
|  |                     }, 1000); | ||||||
|  |                 }, | ||||||
|  |                 function(event) { | ||||||
|  |                     // mouse out event handler | ||||||
|  |                     var elem = $(event.currentTarget); | ||||||
|  |                     if (timer) { | ||||||
|  |                         clearTimeout(timer); | ||||||
|  |                         timer = null; | ||||||
|  |                     } | ||||||
|  |                     else if (xhr) { | ||||||
|  |                         xhr.abort(); | ||||||
|  |                         xhr = null; | ||||||
|  |                     } | ||||||
|  |                     else { | ||||||
|  |                         elem.popover('destroy'); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |         function set_message_count(n) { | ||||||
|  |             $('#message_count').text(n); | ||||||
|  |             $('#message_count').css('visibility', n ? 'visible' : 'hidden'); | ||||||
|  |         } | ||||||
|  |         function set_task_progress(task_id, progress) { | ||||||
|  |             $('#' + task_id + '-progress').text(progress); | ||||||
|  |         } | ||||||
|  |         {% 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++) { | ||||||
|  |                             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); | ||||||
|  |         }); | ||||||
|  |         {% endif %} | ||||||
|     </script> |     </script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  | @ -0,0 +1,7 @@ | ||||||
|  | Dear {{ user.username }}, | ||||||
|  | 
 | ||||||
|  | Please find attached the archive of your posts that you requested. | ||||||
|  | 
 | ||||||
|  | Sincerely, | ||||||
|  | 
 | ||||||
|  | The Microblog Team | ||||||
|  | @ -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">←</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">→</span> | ||||||
|  |                 </a> | ||||||
|  |             </li> | ||||||
|  |         </ul> | ||||||
|  |     </nav> | ||||||
|  | {% endblock %} | ||||||
|  | @ -0,0 +1,22 @@ | ||||||
|  | {% extends "base.html" %} | ||||||
|  | 
 | ||||||
|  | {% block app_content %} | ||||||
|  |     <h1>{{ _('Search Results') }}</h1> | ||||||
|  |     {% for post in posts %} | ||||||
|  |         {% 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">←</span> {{ _('Previous results') }} | ||||||
|  |                 </a> | ||||||
|  |             </li> | ||||||
|  |             <li class="next{% if not next_url %} disabled{% endif %}"> | ||||||
|  |                 <a href="{{ next_url or '#' }}"> | ||||||
|  |                     {{ _('Next results') }} <span aria-hidden="true">→</span> | ||||||
|  |                 </a> | ||||||
|  |             </li> | ||||||
|  |         </ul> | ||||||
|  |     </nav> | ||||||
|  | {% endblock %} | ||||||
|  | @ -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 %} | ||||||
|  | @ -13,6 +13,9 @@ | ||||||
|                 <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p> |                 <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.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> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,32 @@ | ||||||
|  | <table class="table"> | ||||||
|  |     <tr> | ||||||
|  |         <td width="64" style="border: 0px;"><img src="{{ user.avatar(64) }}"></td> | ||||||
|  |         <td style="border: 0px;"> | ||||||
|  |             <p><a href="{{ url_for('main.user', username=user.username) }}">{{ user.username }}</a></p> | ||||||
|  |             <small> | ||||||
|  |                 {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} | ||||||
|  |                 {% if user.last_seen %} | ||||||
|  |                 <p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}</p> | ||||||
|  |                 {% endif %} | ||||||
|  |                 <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p> | ||||||
|  |                 {% if user != current_user %} | ||||||
|  |                     {% if not current_user.is_following(user) %} | ||||||
|  |                     <p> | ||||||
|  |                         <form action="{{ url_for('main.follow', username=user.username) }}" method="post"> | ||||||
|  |                             {{ form.hidden_tag() }} | ||||||
|  |                             {{ form.submit(value=_('Follow'), class_='btn btn-default btn-sm') }} | ||||||
|  |                         </form> | ||||||
|  |                     </p> | ||||||
|  |                     {% else %} | ||||||
|  |                     <p> | ||||||
|  |                         <form action="{{ url_for('main.unfollow', username=user.username) }}" method="post"> | ||||||
|  |                             {{ form.hidden_tag() }} | ||||||
|  |                             {{ form.submit(value=_('Unfollow'), class_='btn btn-default btm-sm') }} | ||||||
|  |                         </form> | ||||||
|  |                     </p> | ||||||
|  |                     {% endif %} | ||||||
|  |                 {% endif %} | ||||||
|  |             </small> | ||||||
|  |         </td> | ||||||
|  |     </tr> | ||||||
|  | </table> | ||||||
|  | @ -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 17:17-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:17 | #: 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." | ||||||
| 
 | 
 | ||||||
|  | @ -34,43 +34,43 @@ msgstr "Error el servicio de traducciones ha fallado." | ||||||
| msgid "[Microblog] Reset Your Password" | msgid "[Microblog] Reset Your Password" | ||||||
| msgstr "[Microblog] Nueva Contraseña" | msgstr "[Microblog] Nueva Contraseña" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:9 app/auth/forms.py:16 app/main/forms.py:10 | #: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10 | ||||||
| msgid "Username" | msgid "Username" | ||||||
| msgstr "Nombre de usuario" | msgstr "Nombre de usuario" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:10 app/auth/forms.py:18 app/auth/forms.py:41 | #: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42 | ||||||
| msgid "Password" | msgid "Password" | ||||||
| msgstr "Contraseña" | msgstr "Contraseña" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:11 | #: app/auth/forms.py:12 | ||||||
| msgid "Remember Me" | msgid "Remember Me" | ||||||
| msgstr "Recordarme" | msgstr "Recordarme" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:12 app/templates/auth/login.html:5 | #: app/auth/forms.py:13 app/templates/auth/login.html:5 | ||||||
| msgid "Sign In" | msgid "Sign In" | ||||||
| msgstr "Ingresar" | msgstr "Ingresar" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:17 app/auth/forms.py:36 | #: app/auth/forms.py:18 app/auth/forms.py:37 | ||||||
| msgid "Email" | msgid "Email" | ||||||
| msgstr "Email" | msgstr "Email" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:20 app/auth/forms.py:43 | #: app/auth/forms.py:21 app/auth/forms.py:44 | ||||||
| msgid "Repeat Password" | msgid "Repeat Password" | ||||||
| msgstr "Repetir Contraseña" | msgstr "Repetir Contraseña" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:22 app/templates/auth/register.html:5 | #: app/auth/forms.py:23 app/templates/auth/register.html:5 | ||||||
| msgid "Register" | msgid "Register" | ||||||
| msgstr "Registrarse" | msgstr "Registrarse" | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:27 app/main/forms.py:23 | #: app/auth/forms.py:28 app/main/forms.py:23 | ||||||
| msgid "Please use a different username." | msgid "Please use a different username." | ||||||
| msgstr "Por favor use un nombre de usuario diferente." | msgstr "Por favor use un nombre de usuario diferente." | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:32 | #: app/auth/forms.py:33 | ||||||
| msgid "Please use a different email address." | msgid "Please use a different email address." | ||||||
| msgstr "Por favor use una dirección de email diferente." | msgstr "Por favor use una dirección de email diferente." | ||||||
| 
 | 
 | ||||||
| #: app/auth/forms.py:37 app/auth/forms.py:45 | #: app/auth/forms.py:38 app/auth/forms.py:46 | ||||||
| msgid "Request Password Reset" | msgid "Request Password Reset" | ||||||
| msgstr "Pedir una nueva contraseña" | msgstr "Pedir una nueva contraseña" | ||||||
| 
 | 
 | ||||||
|  | @ -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" | ||||||
| 
 | 
 | ||||||
|  | @ -102,47 +102,71 @@ msgstr "Enviar" | ||||||
| msgid "Say something" | msgid "Say something" | ||||||
| msgstr "Dí algo" | msgstr "Dí algo" | ||||||
| 
 | 
 | ||||||
| #: app/main/routes.py:35 | #: app/main/forms.py:32 | ||||||
|  | msgid "Search" | ||||||
|  | msgstr "Buscar" | ||||||
|  | 
 | ||||||
|  | #: app/main/forms.py:43 | ||||||
|  | msgid "Message" | ||||||
|  | msgstr "Mensaje" | ||||||
|  | 
 | ||||||
|  | #: app/main/routes.py:36 | ||||||
| msgid "Your post is now live!" | msgid "Your post is now live!" | ||||||
| msgstr "¡Tu artículo ha sido publicado!" | msgstr "¡Tu artículo ha sido publicado!" | ||||||
| 
 | 
 | ||||||
| #: app/main/routes.py:86 | #: 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:91 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:100 app/main/routes.py:116 | #: 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:103 | #: 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:107 | #: 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:119 | #: 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:123 | #: 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" | ||||||
| 
 | 
 | ||||||
|  | @ -158,19 +182,23 @@ msgstr "Inicio" | ||||||
| msgid "Explore" | msgid "Explore" | ||||||
| msgstr "Explorar" | msgstr "Explorar" | ||||||
| 
 | 
 | ||||||
| #: app/templates/base.html:26 | #: app/templates/base.html:33 | ||||||
| msgid "Login" | msgid "Login" | ||||||
| msgstr "Ingresar" | msgstr "Ingresar" | ||||||
| 
 | 
 | ||||||
| #: app/templates/base.html:28 | #: 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:29 | #: app/templates/base.html:46 | ||||||
| msgid "Logout" | msgid "Logout" | ||||||
| msgstr "Salir" | msgstr "Salir" | ||||||
| 
 | 
 | ||||||
| #: app/templates/base.html:66 | #: 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." | ||||||
| 
 | 
 | ||||||
|  | @ -179,28 +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 | ||||||
|  | msgid "Search Results" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | #: app/templates/search.html:12 | ||||||
|  | msgid "Previous results" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | #: app/templates/search.html:17 | ||||||
|  | msgid "Next results" | ||||||
|  | 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" | ||||||
|  | @ -210,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?" | ||||||
|  | @ -256,3 +317,4 @@ msgstr "Ha ocurrido un error inesperado" | ||||||
| #: app/templates/errors/500.html:5 | #: app/templates/errors/500.html:5 | ||||||
| msgid "The administrator has been notified. Sorry for the inconvenience!" | msgid "The administrator has been notified. Sorry for the inconvenience!" | ||||||
| msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" | msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" | ||||||
|  | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,13 @@ | ||||||
|  | #!/bin/bash | ||||||
|  | # this script is used to boot a Docker container | ||||||
|  | source venv/bin/activate | ||||||
|  | while true; do | ||||||
|  |     flask db upgrade | ||||||
|  |     if [[ "$?" == "0" ]]; then | ||||||
|  |         break | ||||||
|  |     fi | ||||||
|  |     echo Deploy command failed, retrying in 5 secs... | ||||||
|  |     sleep 5 | ||||||
|  | done | ||||||
|  | flask translate compile | ||||||
|  | exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app | ||||||
|  | @ -7,9 +7,11 @@ load_dotenv(os.path.join(basedir, '.env')) | ||||||
| 
 | 
 | ||||||
| class Config(object): | class Config(object): | ||||||
|     SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' |     SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' | ||||||
|     SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ |     SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace( | ||||||
|  |         'postgres://', 'postgresql://') or \ | ||||||
|         'sqlite:///' + os.path.join(basedir, 'app.db') |         'sqlite:///' + os.path.join(basedir, 'app.db') | ||||||
|     SQLALCHEMY_TRACK_MODIFICATIONS = False |     SQLALCHEMY_TRACK_MODIFICATIONS = False | ||||||
|  |     LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') | ||||||
|     MAIL_SERVER = os.environ.get('MAIL_SERVER') |     MAIL_SERVER = os.environ.get('MAIL_SERVER') | ||||||
|     MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) |     MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) | ||||||
|     MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None |     MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None | ||||||
|  | @ -18,4 +20,6 @@ class Config(object): | ||||||
|     ADMINS = ['your-email@example.com'] |     ADMINS = ['your-email@example.com'] | ||||||
|     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') | ||||||
|  |     REDIS_URL = os.environ.get('REDIS_URL') or 'redis://' | ||||||
|     POSTS_PER_PAGE = 25 |     POSTS_PER_PAGE = 25 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,37 @@ | ||||||
|  | server { | ||||||
|  |     # listen on port 80 (http) | ||||||
|  |     listen 80; | ||||||
|  |     server_name _; | ||||||
|  |     location / { | ||||||
|  |         # redirect any requests to the same URL but on https | ||||||
|  |         return 301 https://$host$request_uri; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | server { | ||||||
|  |     # listen on port 443 (https) | ||||||
|  |     listen 443 ssl; | ||||||
|  |     server_name _; | ||||||
|  | 
 | ||||||
|  |     # location of the self-signed SSL certificate | ||||||
|  |     ssl_certificate /home/ubuntu/microblog/certs/cert.pem; | ||||||
|  |     ssl_certificate_key /home/ubuntu/microblog/certs/key.pem; | ||||||
|  | 
 | ||||||
|  |     # write access and error logs to /var/log | ||||||
|  |     access_log /var/log/microblog_access.log; | ||||||
|  |     error_log /var/log/microblog_error.log; | ||||||
|  | 
 | ||||||
|  |     location / { | ||||||
|  |         # forward application requests to the gunicorn server | ||||||
|  |         proxy_pass http://localhost:8000; | ||||||
|  |         proxy_redirect off; | ||||||
|  |         proxy_set_header Host $host; | ||||||
|  |         proxy_set_header X-Real-IP $remote_addr; | ||||||
|  |         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     location /static { | ||||||
|  |         # handle static files directly, without forwarding to the application | ||||||
|  |         alias /home/ubuntu/microblog/app/static; | ||||||
|  |         expires 30d; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | [program:microblog] | ||||||
|  | command=/home/ubuntu/microblog/venv/bin/gunicorn -b localhost:8000 -w 4 microblog:app | ||||||
|  | directory=/home/ubuntu/microblog | ||||||
|  | user=ubuntu | ||||||
|  | autostart=true | ||||||
|  | autorestart=true | ||||||
|  | stopasgroup=true | ||||||
|  | killasgroup=true | ||||||
|  | @ -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, Task | ||||||
| 
 | 
 | ||||||
| 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, 'Task': Task} | ||||||
|  |  | ||||||
|  | @ -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 ### | ||||||
|  | @ -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=True), | ||||||
|  |     sa.Column('description', sa.String(length=128), nullable=True), | ||||||
|  |     sa.Column('user_id', sa.Integer(), nullable=True), | ||||||
|  |     sa.Column('complete', sa.Boolean(), nullable=True), | ||||||
|  |     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 ### | ||||||
|  | @ -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 ### | ||||||
|  | @ -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 ### | ||||||
|  | @ -6,10 +6,12 @@ chardet==4.0.0 | ||||||
| click==8.0.1 | click==8.0.1 | ||||||
| dnspython==2.1.0 | dnspython==2.1.0 | ||||||
| dominate==2.6.0 | dominate==2.6.0 | ||||||
|  | elasticsearch==7.13.3 | ||||||
| email-validator==1.1.3 | email-validator==1.1.3 | ||||||
| Flask==2.0.1 | Flask==2.0.1 | ||||||
| Flask-Babel==2.0.0 | Flask-Babel==2.0.0 | ||||||
| Flask-Bootstrap==3.3.7.1 | Flask-Bootstrap==3.3.7.1 | ||||||
|  | Flask-HTTPAuth==4.4.0 | ||||||
| Flask-Login==0.5.0 | Flask-Login==0.5.0 | ||||||
| Flask-Mail==0.9.1 | Flask-Mail==0.9.1 | ||||||
| Flask-Migrate==3.0.1 | Flask-Migrate==3.0.1 | ||||||
|  | @ -17,21 +19,31 @@ Flask-Moment==1.0.1 | ||||||
| Flask-SQLAlchemy==2.5.1 | Flask-SQLAlchemy==2.5.1 | ||||||
| Flask-WTF==0.15.1 | Flask-WTF==0.15.1 | ||||||
| greenlet==1.1.0 | greenlet==1.1.0 | ||||||
|  | httpie==2.4.0 | ||||||
| idna==2.10 | idna==2.10 | ||||||
| itsdangerous==2.0.1 | itsdangerous==2.0.1 | ||||||
| Jinja2==3.0.1 | Jinja2==3.0.1 | ||||||
| langdetect==1.0.9 | langdetect==1.0.9 | ||||||
| Mako==1.1.4 | Mako==1.1.4 | ||||||
| MarkupSafe==2.0.1 | MarkupSafe==2.0.1 | ||||||
|  | Pygments==2.9.0 | ||||||
| PyJWT==2.1.0 | PyJWT==2.1.0 | ||||||
|  | PySocks==1.7.1 | ||||||
| python-dateutil==2.8.1 | python-dateutil==2.8.1 | ||||||
| python-dotenv==0.18.0 | python-dotenv==0.18.0 | ||||||
| python-editor==1.0.4 | python-editor==1.0.4 | ||||||
| pytz==2021.1 | pytz==2021.1 | ||||||
|  | redis==3.5.3 | ||||||
| requests==2.25.1 | requests==2.25.1 | ||||||
|  | requests-toolbelt==0.9.1 | ||||||
|  | rq==1.9.0 | ||||||
| six==1.16.0 | six==1.16.0 | ||||||
| SQLAlchemy==1.4.20 | SQLAlchemy==1.4.20 | ||||||
| urllib3==1.26.6 | urllib3==1.26.6 | ||||||
| visitor==0.1.3 | visitor==0.1.3 | ||||||
| Werkzeug==2.0.1 | Werkzeug==2.0.1 | ||||||
| WTForms==2.3.3 | WTForms==2.3.3 | ||||||
|  | 
 | ||||||
|  | # requirements for Heroku | ||||||
|  | #psycopg2==2.9.1 | ||||||
|  | #gunicorn==20.1.0 | ||||||
|  |  | ||||||
							
								
								
									
										1
									
								
								tests.py
								
								
								
								
							
							
						
						
									
										1
									
								
								tests.py
								
								
								
								
							|  | @ -9,6 +9,7 @@ from config import Config | ||||||
| class TestConfig(Config): | class TestConfig(Config): | ||||||
|     TESTING = True |     TESTING = True | ||||||
|     SQLALCHEMY_DATABASE_URI = 'sqlite://' |     SQLALCHEMY_DATABASE_URI = 'sqlite://' | ||||||
|  |     ELASTICSEARCH_URL = None | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class UserModelCase(unittest.TestCase): | class UserModelCase(unittest.TestCase): | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue