diff --git a/.flaskenv b/.flaskenv index 6006c26dc..c3fbfe877 100644 --- a/.flaskenv +++ b/.flaskenv @@ -1 +1,2 @@ FLASK_APP=microblog.py +FLASK_DEBUG=1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..972b18b18 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:slim + +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +RUN 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 flask translate compile + +EXPOSE 5000 +ENTRYPOINT ["./boot.sh"] diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..216c63996 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: flask db upgrade; flask translate compile; gunicorn microblog:app diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..b5126023e --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,7 @@ +Vagrant.configure("2") do |config| + config.vm.box = "ubuntu/jammy64" + config.vm.network "private_network", ip: "192.168.56.10" + config.vm.provider "virtualbox" do |vb| + vb.memory = "2048" + end +end diff --git a/app/__init__.py b/app/__init__.py index 96c8ef5dc..de8d6a085 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,92 @@ -from flask import Flask +import logging +from logging.handlers import SMTPHandler, RotatingFileHandler +import os +from flask import Flask, request, current_app +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_mail import Mail +from flask_moment import Moment +from flask_babel import Babel, lazy_gettext as _l +from elasticsearch import Elasticsearch +from config import Config -app = Flask(__name__) -from app import routes +def get_locale(): + return request.accept_languages.best_match(current_app.config['LANGUAGES']) + + +db = SQLAlchemy() +migrate = Migrate() +login = LoginManager() +login.login_view = 'auth.login' +login.login_message = _l('Please log in to access this page.') +mail = Mail() +moment = Moment() +babel = Babel() + + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + migrate.init_app(app, db) + login.init_app(app) + mail.init_app(app) + moment.init_app(app) + babel.init_app(app, locale_selector=get_locale) + app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ + if app.config['ELASTICSEARCH_URL'] else None + + from app.errors import bp as errors_bp + app.register_blueprint(errors_bp) + + from app.auth import bp as auth_bp + app.register_blueprint(auth_bp, url_prefix='/auth') + + from app.main import bp as main_bp + app.register_blueprint(main_bp) + + from app.cli import bp as cli_bp + app.register_blueprint(cli_bp) + + if not app.debug and not app.testing: + if app.config['MAIL_SERVER']: + auth = None + if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: + auth = (app.config['MAIL_USERNAME'], + app.config['MAIL_PASSWORD']) + secure = None + if app.config['MAIL_USE_TLS']: + secure = () + mail_handler = SMTPHandler( + mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), + fromaddr='no-reply@' + app.config['MAIL_SERVER'], + toaddrs=app.config['ADMINS'], subject='Microblog Failure', + credentials=auth, secure=secure) + mail_handler.setLevel(logging.ERROR) + 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'): + os.mkdir('logs') + file_handler = RotatingFileHandler('logs/microblog.log', + maxBytes=10240, backupCount=10) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s: %(message)s ' + '[in %(pathname)s:%(lineno)d]')) + file_handler.setLevel(logging.INFO) + app.logger.addHandler(file_handler) + + app.logger.setLevel(logging.INFO) + app.logger.info('Microblog startup') + + return app + + +from app import models diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 000000000..088b0336b --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes diff --git a/app/auth/email.py b/app/auth/email.py new file mode 100644 index 000000000..98755acee --- /dev/null +++ b/app/auth/email.py @@ -0,0 +1,14 @@ +from flask import render_template, current_app +from flask_babel import _ +from app.email import send_email + + +def send_password_reset_email(user): + token = user.get_reset_password_token() + send_email(_('[Microblog] Reset Your Password'), + sender=current_app.config['ADMINS'][0], + recipients=[user.email], + text_body=render_template('email/reset_password.txt', + user=user, token=token), + html_body=render_template('email/reset_password.html', + user=user, token=token)) diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 000000000..d8a4bad17 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,49 @@ +from flask_wtf import FlaskForm +from flask_babel import _, lazy_gettext as _l +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo +import sqlalchemy as sa +from app import db +from app.models import User + + +class LoginForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + password = PasswordField(_l('Password'), validators=[DataRequired()]) + remember_me = BooleanField(_l('Remember Me')) + submit = SubmitField(_l('Sign In')) + + +class RegistrationForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + email = StringField(_l('Email'), validators=[DataRequired(), Email()]) + password = PasswordField(_l('Password'), validators=[DataRequired()]) + password2 = PasswordField( + _l('Repeat Password'), validators=[DataRequired(), + EqualTo('password')]) + submit = SubmitField(_l('Register')) + + def validate_username(self, username): + user = db.session.scalar(sa.select(User).where( + User.username == username.data)) + if user is not None: + raise ValidationError(_('Please use a different username.')) + + def validate_email(self, email): + user = db.session.scalar(sa.select(User).where( + User.email == email.data)) + if user is not None: + raise ValidationError(_('Please use a different email address.')) + + +class ResetPasswordRequestForm(FlaskForm): + email = StringField(_l('Email'), validators=[DataRequired(), Email()]) + submit = SubmitField(_l('Request Password Reset')) + + +class ResetPasswordForm(FlaskForm): + password = PasswordField(_l('Password'), validators=[DataRequired()]) + password2 = PasswordField( + _l('Repeat Password'), validators=[DataRequired(), + EqualTo('password')]) + submit = SubmitField(_l('Request Password Reset')) diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 000000000..79360ff7b --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,85 @@ +from flask import render_template, redirect, url_for, flash, request +from urllib.parse import urlsplit +from flask_login import login_user, logout_user, current_user +from flask_babel import _ +import sqlalchemy as sa +from app import db +from app.auth import bp +from app.auth.forms import LoginForm, RegistrationForm, \ + ResetPasswordRequestForm, ResetPasswordForm +from app.models import User +from app.auth.email import send_password_reset_email + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = LoginForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == form.username.data)) + if user is None or not user.check_password(form.password.data): + flash(_('Invalid username or password')) + return redirect(url_for('auth.login')) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or urlsplit(next_page).netloc != '': + next_page = url_for('main.index') + return redirect(next_page) + return render_template('auth/login.html', title=_('Sign In'), form=form) + + +@bp.route('/logout') +def logout(): + logout_user() + return redirect(url_for('main.index')) + + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data, email=form.email.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash(_('Congratulations, you are now a registered user!')) + return redirect(url_for('auth.login')) + return render_template('auth/register.html', title=_('Register'), + form=form) + + +@bp.route('/reset_password_request', methods=['GET', 'POST']) +def reset_password_request(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = ResetPasswordRequestForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.email == form.email.data)) + if user: + send_password_reset_email(user) + flash( + _('Check your email for the instructions to reset your password')) + return redirect(url_for('auth.login')) + return render_template('auth/reset_password_request.html', + title=_('Reset Password'), form=form) + + +@bp.route('/reset_password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + user = User.verify_reset_password_token(token) + if not user: + return redirect(url_for('main.index')) + form = ResetPasswordForm() + if form.validate_on_submit(): + user.set_password(form.password.data) + db.session.commit() + flash(_('Your password has been reset.')) + return redirect(url_for('auth.login')) + return render_template('auth/reset_password.html', form=form) diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 000000000..25e2a2750 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,40 @@ +import os +from flask import Blueprint +import click + +bp = Blueprint('cli', __name__, cli_group=None) + + +@bp.cli.group() +def translate(): + """Translation and localization commands.""" + pass + + +@translate.command() +@click.argument('lang') +def init(lang): + """Initialize a new language.""" + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system( + 'pybabel init -i messages.pot -d app/translations -l ' + lang): + raise RuntimeError('init command failed') + os.remove('messages.pot') + + +@translate.command() +def update(): + """Update all languages.""" + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system('pybabel update -i messages.pot -d app/translations'): + raise RuntimeError('update command failed') + os.remove('messages.pot') + + +@translate.command() +def compile(): + """Compile all languages.""" + if os.system('pybabel compile -d app/translations'): + raise RuntimeError('compile command failed') diff --git a/app/email.py b/app/email.py new file mode 100644 index 000000000..ee23da84c --- /dev/null +++ b/app/email.py @@ -0,0 +1,17 @@ +from threading import Thread +from flask import current_app +from flask_mail import Message +from app import mail + + +def send_async_email(app, msg): + with app.app_context(): + mail.send(msg) + + +def send_email(subject, sender, recipients, text_body, html_body): + msg = Message(subject, sender=sender, recipients=recipients) + msg.body = text_body + msg.html = html_body + Thread(target=send_async_email, + args=(current_app._get_current_object(), msg)).start() diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 000000000..5701c1d11 --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from app.errors import handlers diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 000000000..4a40ad9e5 --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,14 @@ +from flask import render_template +from app import db +from app.errors import bp + + +@bp.app_errorhandler(404) +def not_found_error(error): + return render_template('errors/404.html'), 404 + + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('errors/500.html'), 500 diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 000000000..3b580b073 --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from app.main import routes diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 000000000..135beae1c --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,53 @@ +from flask import request +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField +from wtforms.validators import ValidationError, DataRequired, Length +import sqlalchemy as sa +from flask_babel import _, lazy_gettext as _l +from app import db +from app.models import User + + +class EditProfileForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + about_me = TextAreaField(_l('About me'), + validators=[Length(min=0, max=140)]) + submit = SubmitField(_l('Submit')) + + def __init__(self, original_username, *args, **kwargs): + super().__init__(*args, **kwargs) + self.original_username = original_username + + def validate_username(self, username): + if username.data != self.original_username: + user = db.session.scalar(sa.select(User).where( + User.username == self.username.data)) + if user is not None: + raise ValidationError(_('Please use a different username.')) + + +class EmptyForm(FlaskForm): + submit = SubmitField('Submit') + + +class PostForm(FlaskForm): + post = TextAreaField(_l('Say something'), validators=[ + DataRequired(), Length(min=1, max=140)]) + 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')) diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 000000000..8270338c9 --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,235 @@ +from datetime import datetime, timezone +from flask import render_template, flash, redirect, url_for, request, g, \ + current_app +from flask_login import current_user, login_required +from flask_babel import _, get_locale +import sqlalchemy as sa +from langdetect import detect, LangDetectException +from app import db +from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm, \ + MessageForm +from app.models import User, Post, Message, Notification +from app.translate import translate +from app.main import bp +from app import app + + +@bp.before_app_request +def before_request(): + if current_user.is_authenticated: + current_user.last_seen = datetime.now(timezone.utc) + db.session.commit() + g.search_form = SearchForm() + g.locale = str(get_locale()) + + +@bp.route('/', methods=['GET', 'POST']) +@bp.route('/index', methods=['GET', 'POST']) +@login_required +def index(): + form = PostForm() + if form.validate_on_submit(): + try: + language = detect(form.post.data) + except LangDetectException: + language = '' + post = Post(body=form.post.data, author=current_user, + language=language) + db.session.add(post) + db.session.commit() + flash(_('Your post is now live!')) + return redirect(url_for('main.index')) + page = request.args.get('page', 1, type=int) + posts = db.paginate(current_user.following_posts(), page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.index', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('main.index', page=posts.prev_num) \ + if posts.has_prev else None + return render_template('index.html', title=_('Home'), form=form, + posts=posts.items, next_url=next_url, + prev_url=prev_url) + + +@bp.route('/explore') +@login_required +def explore(): + page = request.args.get('page', 1, type=int) + query = sa.select(Post).order_by(Post.timestamp.desc()) + posts = db.paginate(query, page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.explore', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('main.explore', page=posts.prev_num) \ + if posts.has_prev else None + return render_template('index.html', title=_('Explore'), + posts=posts.items, next_url=next_url, + prev_url=prev_url) + + +@bp.route('/user/') +@login_required +def user(username): + user = db.first_or_404(sa.select(User).where(User.username == username)) + page = request.args.get('page', 1, type=int) + query = user.posts.select().order_by(Post.timestamp.desc()) + posts = db.paginate(query, page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.user', username=user.username, + page=posts.next_num) if posts.has_next else None + prev_url = url_for('main.user', username=user.username, + page=posts.prev_num) if posts.has_prev else None + form = EmptyForm() + return render_template('user.html', user=user, posts=posts.items, + next_url=next_url, prev_url=prev_url, form=form) + + +@bp.route('/user//popup') +@login_required +def user_popup(username): + user = db.first_or_404(sa.select(User).where(User.username == username)) + form = EmptyForm() + return render_template('user_popup.html', user=user, form=form) + + +@bp.route('/edit_profile', methods=['GET', 'POST']) +@login_required +def edit_profile(): + form = EditProfileForm(current_user.username) + if form.validate_on_submit(): + current_user.username = form.username.data + current_user.about_me = form.about_me.data + db.session.commit() + flash(_('Your changes have been saved.')) + return redirect(url_for('main.edit_profile')) + elif request.method == 'GET': + form.username.data = current_user.username + form.about_me.data = current_user.about_me + return render_template('edit_profile.html', title=_('Edit Profile'), + form=form) + + +@bp.route('/follow/', methods=['POST']) +@login_required +def follow(username): + form = EmptyForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == username)) + if user is None: + flash(_('User %(username)s not found.', username=username)) + return redirect(url_for('main.index')) + if user == current_user: + flash(_('You cannot follow yourself!')) + return redirect(url_for('main.user', username=username)) + current_user.follow(user) + db.session.commit() + flash(_('You are following %(username)s!', username=username)) + return redirect(url_for('main.user', username=username)) + else: + return redirect(url_for('main.index')) + + +@bp.route('/unfollow/', methods=['POST']) +@login_required +def unfollow(username): + form = EmptyForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == username)) + if user is None: + flash(_('User %(username)s not found.', username=username)) + return redirect(url_for('main.index')) + if user == current_user: + flash(_('You cannot unfollow yourself!')) + return redirect(url_for('main.user', username=username)) + current_user.unfollow(user) + db.session.commit() + flash(_('You are not following %(username)s.', username=username)) + return redirect(url_for('main.user', username=username)) + else: + return redirect(url_for('main.index')) + + +@bp.route('/translate', methods=['POST']) +@login_required +def translate_text(): + data = request.get_json() + return {'text': translate(data['text'], + data['source_language'], + data['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/', methods=['GET', 'POST']) +@login_required +def send_message(recipient): + user = db.first_or_404(sa.select(User).where(User.username == recipient)) + form = MessageForm() + if form.validate_on_submit(): + msg = Message(author=current_user, recipient=user, + body=form.message.data) + db.session.add(msg) + user.add_notification('unread_message_count', + user.unread_message_count()) + db.session.commit() + flash(_('Your message has been sent.')) + return redirect(url_for('main.user', username=recipient)) + return render_template('send_message.html', title=_('Send Message'), + form=form, recipient=recipient) + + +@bp.route('/messages') +@login_required +def messages(): + current_user.last_message_read_time = datetime.now(timezone.utc) + current_user.add_notification('unread_message_count', 0) + db.session.commit() + page = request.args.get('page', 1, type=int) + query = current_user.messages_received.select().order_by( + Message.timestamp.desc()) + messages = db.paginate(query, page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.messages', page=messages.next_num) \ + if messages.has_next else None + prev_url = url_for('main.messages', page=messages.prev_num) \ + if messages.has_prev else None + return render_template('messages.html', messages=messages.items, + next_url=next_url, prev_url=prev_url) + + +@bp.route('/notifications') +@login_required +def notifications(): + since = request.args.get('since', 0.0, type=float) + query = current_user.notifications.select().where( + Notification.timestamp > since).order_by(Notification.timestamp.asc()) + notifications = db.session.scalars(query) + return [{ + 'name': n.name, + 'data': n.get_data(), + 'timestamp': n.timestamp + } for n in notifications] + +@app.route('/about') +def about(): + return render_template('about.html', title='A propos') diff --git a/app/models.py b/app/models.py new file mode 100644 index 000000000..0d10d7ca1 --- /dev/null +++ b/app/models.py @@ -0,0 +1,231 @@ +from datetime import datetime, timezone +from hashlib import md5 +import json +from time import time +from typing import Optional +import sqlalchemy as sa +import sqlalchemy.orm as so +from flask import current_app +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +import jwt +from app import db, login +from app.search import add_to_index, remove_from_index, query_index + + +class SearchableMixin: + @classmethod + def search(cls, expression, page, per_page): + ids, total = query_index(cls.__tablename__, expression, page, per_page) + if total == 0: + return [], 0 + when = [] + for i in range(len(ids)): + when.append((ids[i], i)) + query = sa.select(cls).where(cls.id.in_(ids)).order_by( + db.case(*when, value=cls.id)) + return db.session.scalars(query), 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 db.session.scalars(sa.select(cls)): + 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) + + +followers = sa.Table( + 'followers', + db.metadata, + sa.Column('follower_id', sa.Integer, sa.ForeignKey('user.id'), + primary_key=True), + sa.Column('followed_id', sa.Integer, sa.ForeignKey('user.id'), + primary_key=True) +) + + +class User(UserMixin, db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, + unique=True) + email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, + unique=True) + password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256)) + about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140)) + last_seen: so.Mapped[Optional[datetime]] = so.mapped_column( + default=lambda: datetime.now(timezone.utc)) + last_message_read_time: so.Mapped[Optional[datetime]] + + posts: so.WriteOnlyMapped['Post'] = so.relationship( + back_populates='author') + following: so.WriteOnlyMapped['User'] = so.relationship( + secondary=followers, primaryjoin=(followers.c.follower_id == id), + secondaryjoin=(followers.c.followed_id == id), + back_populates='followers') + followers: so.WriteOnlyMapped['User'] = so.relationship( + secondary=followers, primaryjoin=(followers.c.followed_id == id), + secondaryjoin=(followers.c.follower_id == id), + back_populates='following') + messages_sent: so.WriteOnlyMapped['Message'] = so.relationship( + foreign_keys='Message.sender_id', back_populates='author') + messages_received: so.WriteOnlyMapped['Message'] = so.relationship( + foreign_keys='Message.recipient_id', back_populates='recipient') + notifications: so.WriteOnlyMapped['Notification'] = so.relationship( + back_populates='user') + + def __repr__(self): + return ''.format(self.username) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def avatar(self, size): + digest = md5(self.email.lower().encode('utf-8')).hexdigest() + return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}' + + def follow(self, user): + if not self.is_following(user): + self.following.add(user) + + def unfollow(self, user): + if self.is_following(user): + self.following.remove(user) + + def is_following(self, user): + query = self.following.select().where(User.id == user.id) + return db.session.scalar(query) is not None + + def followers_count(self): + query = sa.select(sa.func.count()).select_from( + self.followers.select().subquery()) + return db.session.scalar(query) + + def following_count(self): + query = sa.select(sa.func.count()).select_from( + self.following.select().subquery()) + return db.session.scalar(query) + + def following_posts(self): + Author = so.aliased(User) + Follower = so.aliased(User) + return ( + sa.select(Post) + .join(Post.author.of_type(Author)) + .join(Author.followers.of_type(Follower), isouter=True) + .where(sa.or_( + Follower.id == self.id, + Author.id == self.id, + )) + .group_by(Post) + .order_by(Post.timestamp.desc()) + ) + + def get_reset_password_token(self, expires_in=600): + return jwt.encode( + {'reset_password': self.id, 'exp': time() + expires_in}, + current_app.config['SECRET_KEY'], algorithm='HS256') + + @staticmethod + def verify_reset_password_token(token): + try: + id = jwt.decode(token, current_app.config['SECRET_KEY'], + algorithms=['HS256'])['reset_password'] + except Exception: + return + return db.session.get(User, id) + + def unread_message_count(self): + last_read_time = self.last_message_read_time or datetime(1900, 1, 1) + query = sa.select(Message).where(Message.recipient == self, + Message.timestamp > last_read_time) + return db.session.scalar(sa.select(sa.func.count()).select_from( + query.subquery())) + + def add_notification(self, name, data): + db.session.execute(self.notifications.delete().where( + Notification.name == name)) + n = Notification(name=name, payload_json=json.dumps(data), user=self) + db.session.add(n) + return n + + +@login.user_loader +def load_user(id): + return db.session.get(User, int(id)) + + +class Post(SearchableMixin, db.Model): + __searchable__ = ['body'] + id: so.Mapped[int] = so.mapped_column(primary_key=True) + body: so.Mapped[str] = so.mapped_column(sa.String(140)) + timestamp: so.Mapped[datetime] = so.mapped_column( + index=True, default=lambda: datetime.now(timezone.utc)) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + language: so.Mapped[Optional[str]] = so.mapped_column(sa.String(5)) + + author: so.Mapped[User] = so.relationship(back_populates='posts') + + def __repr__(self): + return ''.format(self.body) + + +class Message(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + sender_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + recipient_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + body: so.Mapped[str] = so.mapped_column(sa.String(140)) + timestamp: so.Mapped[datetime] = so.mapped_column( + index=True, default=lambda: datetime.now(timezone.utc)) + + author: so.Mapped[User] = so.relationship( + foreign_keys='Message.sender_id', + back_populates='messages_sent') + recipient: so.Mapped[User] = so.relationship( + foreign_keys='Message.recipient_id', + back_populates='messages_received') + + def __repr__(self): + return ''.format(self.body) + + +class Notification(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + timestamp: so.Mapped[float] = so.mapped_column(index=True, default=time) + payload_json: so.Mapped[str] = so.mapped_column(sa.Text) + + user: so.Mapped[User] = so.relationship(back_populates='notifications') + + def get_data(self): + return json.loads(str(self.payload_json)) diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index 69701e322..000000000 --- a/app/routes.py +++ /dev/null @@ -1,7 +0,0 @@ -from app import app - - -@app.route('/') -@app.route('/index') -def index(): - return "Hello, World!" diff --git a/app/search.py b/app/search.py new file mode 100644 index 000000000..51c5c9609 --- /dev/null +++ b/app/search.py @@ -0,0 +1,28 @@ +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, document=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, + 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'] diff --git a/app/static/loading.gif b/app/static/loading.gif new file mode 100644 index 000000000..d0bce1542 Binary files /dev/null and b/app/static/loading.gif differ diff --git a/app/templates/_post.html b/app/templates/_post.html new file mode 100644 index 000000000..2a1c697ee --- /dev/null +++ b/app/templates/_post.html @@ -0,0 +1,30 @@ + + + + + +
+ + + + + {% set user_link %} + + {{ post.author.username }} + + {% endset %} + {{ _('%(username)s said %(when)s', + username=user_link, when=moment(post.timestamp).fromNow()) }} +
+ {{ post.body }} + {% if post.language and post.language != g.locale %} +

+ + {{ _('Translate') }} + + {% endif %} +
diff --git a/app/templates/about.html b/app/templates/about.html new file mode 100644 index 000000000..176d92630 --- /dev/null +++ b/app/templates/about.html @@ -0,0 +1,13 @@ + + + + + + + {{ title }} + + +

A propos

+

Ceci est une page à propos de notre application.

+ + diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 000000000..4fe56690e --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Sign In') }}

+ {{ wtf.quick_form(form) }} +

{{ _('New User?') }} {{ _('Click to Register!') }}

+

+ {{ _('Forgot Your Password?') }} + {{ _('Click to Reset It') }} +

+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 000000000..d93124d3e --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Register') }}

+ {{ wtf.quick_form(form) }} +{% endblock %} diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 000000000..15123f9c7 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Reset Your Password') }}

+ {{ wtf.quick_form(form) }} +{% endblock %} diff --git a/app/templates/auth/reset_password_request.html b/app/templates/auth/reset_password_request.html new file mode 100644 index 000000000..a0f066d99 --- /dev/null +++ b/app/templates/auth/reset_password_request.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Reset Password') }}

+ {{ wtf.quick_form(form) }} +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 000000000..55e2ff45b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,164 @@ + + + + + + {% if title %} + {{ title }} - Microblog + {% else %} + {{ _('Welcome to Microblog') }} + {% endif %} + + + + + + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + {{ moment.include_moment() }} + {{ moment.lang(g.locale) }} + + + diff --git a/app/templates/bootstrap_wtf.html b/app/templates/bootstrap_wtf.html new file mode 100644 index 000000000..6bc02b940 --- /dev/null +++ b/app/templates/bootstrap_wtf.html @@ -0,0 +1,70 @@ +{% macro form_field(field, autofocus) %} + {%- if field.type == 'BooleanField' %} +
+ {{ field(class='form-check-input') }} + {{ field.label(class='form-check-label') }} +
+ {%- elif field.type == 'RadioField' %} + {{ field.label(class='form-label') }} + {%- for item in field %} +
+ {{ item(class='form-check-input') }} + {{ item.label(class='form-check-label') }} +
+ {%- endfor %} + {%- elif field.type == 'SelectField' %} + {{ field.label(class='form-label') }} + {{ field(class='form-select mb-3') }} + {%- elif field.type == 'TextAreaField' %} +
+ {{ field.label(class='form-label') }} + {% if autofocus %} + {{ field(class='form-control' + (' is-invalid' if field.errors else ''), autofocus=True) }} + {% else %} + {{ field(class='form-control' + (' is-invalid' if field.errors else '')) }} + {% endif %} + {%- for error in field.errors %} +
{{ error }}
+ {%- endfor %} +
+ {%- elif field.type == 'SubmitField' %} + {{ field(class='btn btn-primary mb-3') }} + {%- else %} +
+ {{ field.label(class='form-label') }} + {% if autofocus %} + {{ field(class='form-control' + (' is-invalid' if field.errors else ''), autofocus=True) }} + {% else %} + {{ field(class='form-control' + (' is-invalid' if field.errors else '')) }} + {% endif %} + {%- for error in field.errors %} +
{{ error }}
+ {%- endfor %} +
+ {%- endif %} +{% endmacro %} + +{% macro quick_form(form, action="", method="post", id="", novalidate=False) %} +
+ {{ form.hidden_tag() }} + {%- for field, errors in form.errors.items() %} + {%- if form[field].widget.input_type == 'hidden' %} + {%- for error in errors %} +
{{ error }}
+ {%- endfor %} + {%- endif %} + {%- endfor %} + + {% set ns = namespace(first_field=true) %} + {%- for field in form %} + {% if field.widget.input_type != 'hidden' -%} + {{ form_field(field, ns.first_field) }} + {% set ns.first_field = false %} + {%- endif %} + {%- endfor %} +
+{% endmacro %} diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html new file mode 100644 index 000000000..f067d8f3e --- /dev/null +++ b/app/templates/edit_profile.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Edit Profile') }}

+ {{ wtf.quick_form(form) }} +{% endblock %} diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html new file mode 100644 index 000000000..bca2f0f70 --- /dev/null +++ b/app/templates/email/reset_password.html @@ -0,0 +1,17 @@ + + + +

Dear {{ user.username }},

+

+ To reset your password + + click here + . +

+

Alternatively, you can paste the following link in your browser's address bar:

+

{{ url_for('auth.reset_password', token=token, _external=True) }}

+

If you have not requested a password reset simply ignore this message.

+

Sincerely,

+

The Microblog Team

+ + diff --git a/app/templates/email/reset_password.txt b/app/templates/email/reset_password.txt new file mode 100644 index 000000000..bd107b5c1 --- /dev/null +++ b/app/templates/email/reset_password.txt @@ -0,0 +1,11 @@ +Dear {{ user.username }}, + +To reset your password click on the following link: + +{{ url_for('auth.reset_password', token=token, _external=True) }} + +If you have not requested a password reset simply ignore this message. + +Sincerely, + +The Microblog Team diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 000000000..3cc4cf1d3 --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block content %} +

{{ _('Not Found') }}

+

{{ _('Back') }}

+{% endblock %} diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html new file mode 100644 index 000000000..f494a21d3 --- /dev/null +++ b/app/templates/errors/500.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +

{{ _('An unexpected error has occurred') }}

+

{{ _('The administrator has been notified. Sorry for the inconvenience!') }}

+

{{ _('Back') }}

+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 000000000..3dec86c35 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Hi, %(username)s!', username=current_user.username) }}

+ {% if form %} + {{ wtf.quick_form(form) }} + {% endif %} + {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/app/templates/messages.html b/app/templates/messages.html new file mode 100644 index 000000000..fe09eaeea --- /dev/null +++ b/app/templates/messages.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +

{{ _('Messages') }}

+ {% for post in messages %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/app/templates/search.html b/app/templates/search.html new file mode 100644 index 000000000..db9978e1d --- /dev/null +++ b/app/templates/search.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +

{{ _('Search Results') }}

+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/app/templates/send_message.html b/app/templates/send_message.html new file mode 100644 index 000000000..8e8d77ec2 --- /dev/null +++ b/app/templates/send_message.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block 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 new file mode 100644 index 000000000..7060efe94 --- /dev/null +++ b/app/templates/user.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block content %} + + + + + +
+

{{ _('User') }}: {{ user.username }}

+ {% if user.about_me %}

{{ user.about_me }}

{% endif %} + {% if user.last_seen %} +

{{ _('Last seen on') }}: {{ moment(user.last_seen).format('LLL') }}

+ {% endif %} +

{{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_count()) }}

+ {% if user == current_user %} +

{{ _('Edit your profile') }}

+ {% elif not current_user.is_following(user) %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value=_('Follow'), class_='btn btn-primary') }} +
+

+ {% else %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value=_('Unfollow'), class_='btn btn-primary') }} +
+

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

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

+ {% endif %} +
+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/app/templates/user_popup.html b/app/templates/user_popup.html new file mode 100644 index 000000000..52a386f75 --- /dev/null +++ b/app/templates/user_popup.html @@ -0,0 +1,27 @@ +
+ +

{{ user.username }}

+ {% if user.about_me %}

{{ user.about_me }}

{% endif %} +
+ {% if user.last_seen %} +

{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}

+ {% endif %} +

{{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_count()) }}

+ {% if user != current_user %} + {% if not current_user.is_following(user) %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value=_('Follow'), class_='btn btn-outline-primary btn-sm') }} +
+

+ {% else %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value=_('Unfollow'), class_='btn btn-outline-primary btn-sm') }} +
+

+ {% endif %} + {% endif %} +
diff --git a/app/translate.py b/app/translate.py new file mode 100644 index 000000000..b51a17a8b --- /dev/null +++ b/app/translate.py @@ -0,0 +1,21 @@ +import requests +from flask import current_app +from flask_babel import _ + + +def translate(text, source_language, dest_language): + if 'MS_TRANSLATOR_KEY' not in current_app.config or \ + not current_app.config['MS_TRANSLATOR_KEY']: + return _('Error: the translation service is not configured.') + auth = { + 'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY'], + 'Ocp-Apim-Subscription-Region': 'westus' + } + r = requests.post( + 'https://api.cognitive.microsofttranslator.com' + '/translate?api-version=3.0&from={}&to={}'.format( + source_language, dest_language), headers=auth, json=[ + {'Text': text}]) + if r.status_code != 200: + return _('Error: the translation service failed.') + return r.json()[0]['translations'][0]['text'] diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po new file mode 100644 index 000000000..dac426423 --- /dev/null +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -0,0 +1,308 @@ +# Spanish translations for PROJECT. +# Copyright (C) 2017 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" +"Language-Team: es \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.5.1\n" + +#: app/__init__.py:18 +msgid "Please log in to access this page." +msgstr "Por favor ingrese para acceder a esta página." + +#: app/translate.py:10 +msgid "Error: the translation service is not configured." +msgstr "Error: el servicio de traducciones no está configurado." + +#: app/translate.py:18 +msgid "Error: the translation service failed." +msgstr "Error el servicio de traducciones ha fallado." + +#: app/auth/email.py:8 +msgid "[Microblog] Reset Your Password" +msgstr "[Microblog] Nueva Contraseña" + +#: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10 +msgid "Username" +msgstr "Nombre de usuario" + +#: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42 +msgid "Password" +msgstr "Contraseña" + +#: app/auth/forms.py:12 +msgid "Remember Me" +msgstr "Recordarme" + +#: app/auth/forms.py:13 app/templates/auth/login.html:5 +msgid "Sign In" +msgstr "Ingresar" + +#: app/auth/forms.py:18 app/auth/forms.py:37 +msgid "Email" +msgstr "Email" + +#: app/auth/forms.py:21 app/auth/forms.py:44 +msgid "Repeat Password" +msgstr "Repetir Contraseña" + +#: app/auth/forms.py:23 app/templates/auth/register.html:5 +msgid "Register" +msgstr "Registrarse" + +#: app/auth/forms.py:28 app/main/forms.py:23 +msgid "Please use a different username." +msgstr "Por favor use un nombre de usuario diferente." + +#: app/auth/forms.py:33 +msgid "Please use a different email address." +msgstr "Por favor use una dirección de email diferente." + +#: app/auth/forms.py:38 app/auth/forms.py:46 +msgid "Request Password Reset" +msgstr "Pedir una nueva contraseña" + +#: app/auth/routes.py:20 +msgid "Invalid username or password" +msgstr "Nombre de usuario o contraseña inválidos" + +#: app/auth/routes.py:46 +msgid "Congratulations, you are now a registered user!" +msgstr "¡Felicitaciones, ya eres un usuario registrado!" + +#: app/auth/routes.py:61 +msgid "Check your email for the instructions to reset your password" +msgstr "Busca en tu email las instrucciones para crear una nueva contraseña" + +#: app/auth/routes.py:78 +msgid "Your password has been reset." +msgstr "Tu contraseña ha sido cambiada." + +#: app/main/forms.py:11 +msgid "About me" +msgstr "Acerca de mí" + +#: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44 +msgid "Submit" +msgstr "Enviar" + +#: app/main/forms.py:27 +msgid "Say something" +msgstr "Dí algo" + +#: 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!" +msgstr "¡Tu artículo ha sido publicado!" + +#: app/main/routes.py:94 +msgid "Your changes have been saved." +msgstr "Tus cambios han sido salvados." + +#: app/main/routes.py:99 app/templates/edit_profile.html:5 +msgid "Edit Profile" +msgstr "Editar Perfil" + +#: 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:111 +msgid "You cannot follow yourself!" +msgstr "¡No te puedes seguir a tí mismo!" + +#: 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:127 +msgid "You cannot unfollow yourself!" +msgstr "¡No te puedes dejar de seguir a tí mismo!" + +#: app/main/routes.py:131 +#, python-format +msgid "You are not following %(username)s." +msgstr "No estás siguiendo a %(username)s." + +#: 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:27 +msgid "Translate" +msgstr "Traducir" + +#: app/templates/base.html:4 +msgid "Welcome to Microblog" +msgstr "Bienvenido a Microblog" + +#: app/templates/base.html:21 +msgid "Home" +msgstr "Inicio" + +#: app/templates/base.html:22 +msgid "Explore" +msgstr "Explorar" + +#: app/templates/base.html:33 +msgid "Login" +msgstr "Ingresar" + +#: 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:46 +msgid "Logout" +msgstr "Salir" + +#: app/templates/base.html:83 +msgid "Error: Could not contact server." +msgstr "Error: el servidor no pudo ser contactado." + +#: app/templates/index.html:5 +#, python-format +msgid "Hi, %(username)s!" +msgstr "¡Hola, %(username)s!" + +#: 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: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 "" + +#: 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 +msgid "User" +msgstr "Usuario" + +#: 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_popup.html:11 +#, python-format +msgid "%(count)d followers" +msgstr "%(count)d seguidores" + +#: app/templates/user.html:13 app/templates/user_popup.html:11 +#, python-format +msgid "%(count)d following" +msgstr "siguiendo a %(count)d" + +#: app/templates/user.html:15 +msgid "Edit your profile" +msgstr "Editar tu perfil" + +#: app/templates/user.html:17 app/templates/user_popup.html:14 +msgid "Follow" +msgstr "Seguir" + +#: 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?" + +#: app/templates/auth/login.html:12 +msgid "Click to Register!" +msgstr "¡Haz click aquí para registrarte!" + +#: app/templates/auth/login.html:14 +msgid "Forgot Your Password?" +msgstr "¿Te olvidaste tu contraseña?" + +#: app/templates/auth/login.html:15 +msgid "Click to Reset It" +msgstr "Haz click aquí para pedir una nueva" + +#: app/templates/auth/reset_password.html:5 +msgid "Reset Your Password" +msgstr "Nueva Contraseña" + +#: app/templates/auth/reset_password_request.html:5 +msgid "Reset Password" +msgstr "Nueva Contraseña" + +#: app/templates/errors/404.html:4 +msgid "Not Found" +msgstr "Página No Encontrada" + +#: app/templates/errors/404.html:5 app/templates/errors/500.html:6 +msgid "Back" +msgstr "Atrás" + +#: app/templates/errors/500.html:4 +msgid "An unexpected error has occurred" +msgstr "Ha ocurrido un error inesperado" + +#: app/templates/errors/500.html:5 +msgid "The administrator has been notified. Sorry for the inconvenience!" +msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" + diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 000000000..b42c3d577 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: app/**.py] +[jinja2: app/templates/**.html] diff --git a/boot.sh b/boot.sh new file mode 100755 index 000000000..2f0992d21 --- /dev/null +++ b/boot.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# this script is used to boot a Docker container +while true; do + flask db upgrade + if [[ "$?" == "0" ]]; then + break + fi + echo Deploy command failed, retrying in 5 secs... + sleep 5 +done +exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app diff --git a/config.py b/config.py new file mode 100644 index 000000000..af203e54a --- /dev/null +++ b/config.py @@ -0,0 +1,23 @@ +import os +from dotenv import load_dotenv + +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(basedir, '.env')) + + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace( + 'postgres://', 'postgresql://') or \ + 'sqlite:///' + os.path.join(basedir, 'app.db') + LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + ADMINS = ['your-email@example.com'] + LANGUAGES = ['en', 'es'] + MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') + ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') + POSTS_PER_PAGE = 25 diff --git a/deployment/nginx/microblog b/deployment/nginx/microblog new file mode 100644 index 000000000..784d9972c --- /dev/null +++ b/deployment/nginx/microblog @@ -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; + } +} diff --git a/deployment/supervisor/microblog.conf b/deployment/supervisor/microblog.conf new file mode 100644 index 000000000..134f18ba9 --- /dev/null +++ b/deployment/supervisor/microblog.conf @@ -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 diff --git a/microblog.py b/microblog.py index d099b92db..5f4baa09b 100644 --- a/microblog.py +++ b/microblog.py @@ -1 +1,12 @@ -from app import app +import sqlalchemy as sa +import sqlalchemy.orm as so +from app import create_app, db +from app.models import User, Post, Message, Notification + +app = create_app() + + +@app.shell_context_processor +def make_shell_context(): + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post, + 'Message': Message, 'Notification': Notification} diff --git a/migrations/README b/migrations/README new file mode 100755 index 000000000..98e4f9c44 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 000000000..f8ed4801f --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100755 index 000000000..23663ff2f --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100755 index 000000000..2c0156303 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/2b017edaa91f_add_language_to_posts.py b/migrations/versions/2b017edaa91f_add_language_to_posts.py new file mode 100644 index 000000000..260cbcb82 --- /dev/null +++ b/migrations/versions/2b017edaa91f_add_language_to_posts.py @@ -0,0 +1,32 @@ +"""add language to posts + +Revision ID: 2b017edaa91f +Revises: ae346256b650 +Create Date: 2017-10-04 22:48:34.494465 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2b017edaa91f' +down_revision = 'ae346256b650' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('language', sa.String(length=5), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_column('language') + + # ### end Alembic commands ### diff --git a/migrations/versions/37f06a334dbf_new_fields_in_user_model.py b/migrations/versions/37f06a334dbf_new_fields_in_user_model.py new file mode 100644 index 000000000..d633a5b94 --- /dev/null +++ b/migrations/versions/37f06a334dbf_new_fields_in_user_model.py @@ -0,0 +1,34 @@ +"""new fields in user model + +Revision ID: 37f06a334dbf +Revises: 780739b227a7 +Create Date: 2017-09-14 10:54:13.865401 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '37f06a334dbf' +down_revision = '780739b227a7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('about_me', sa.String(length=140), nullable=True)) + batch_op.add_column(sa.Column('last_seen', sa.DateTime(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('last_seen') + batch_op.drop_column('about_me') + + # ### end Alembic commands ### diff --git a/migrations/versions/780739b227a7_posts_table.py b/migrations/versions/780739b227a7_posts_table.py new file mode 100644 index 000000000..219178ad8 --- /dev/null +++ b/migrations/versions/780739b227a7_posts_table.py @@ -0,0 +1,43 @@ +"""posts table + +Revision ID: 780739b227a7 +Revises: e517276bb1c2 +Create Date: 2017-09-11 12:23:25.496587 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '780739b227a7' +down_revision = 'e517276bb1c2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('post', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('body', sa.String(length=140), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_post_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_post_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_post_user_id')) + batch_op.drop_index(batch_op.f('ix_post_timestamp')) + + op.drop_table('post') + # ### end Alembic commands ### diff --git a/migrations/versions/ae346256b650_followers.py b/migrations/versions/ae346256b650_followers.py new file mode 100644 index 000000000..f41f3c55f --- /dev/null +++ b/migrations/versions/ae346256b650_followers.py @@ -0,0 +1,34 @@ +"""followers + +Revision ID: ae346256b650 +Revises: 37f06a334dbf +Create Date: 2017-09-17 15:41:30.211082 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ae346256b650' +down_revision = '37f06a334dbf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('followers', + sa.Column('follower_id', sa.Integer(), nullable=False), + sa.Column('followed_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('follower_id', 'followed_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('followers') + # ### end Alembic commands ### diff --git a/migrations/versions/d049de007ccf_private_messages.py b/migrations/versions/d049de007ccf_private_messages.py new file mode 100644 index 000000000..f0a081fff --- /dev/null +++ b/migrations/versions/d049de007ccf_private_messages.py @@ -0,0 +1,53 @@ +"""private messages + +Revision ID: d049de007ccf +Revises: 834b1a697901 +Create Date: 2017-11-12 23:30:28.571784 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd049de007ccf' +down_revision = '2b017edaa91f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('message', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('recipient_id', sa.Integer(), nullable=False), + sa.Column('body', sa.String(length=140), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + with op.batch_alter_table('message', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_message_recipient_id'), ['recipient_id'], unique=False) + batch_op.create_index(batch_op.f('ix_message_sender_id'), ['sender_id'], unique=False) + batch_op.create_index(batch_op.f('ix_message_timestamp'), ['timestamp'], unique=False) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('last_message_read_time', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('last_message_read_time') + + with op.batch_alter_table('message', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_message_timestamp')) + batch_op.drop_index(batch_op.f('ix_message_sender_id')) + batch_op.drop_index(batch_op.f('ix_message_recipient_id')) + + op.drop_table('message') + # ### end Alembic commands ### diff --git a/migrations/versions/e517276bb1c2_users_table.py b/migrations/versions/e517276bb1c2_users_table.py new file mode 100644 index 000000000..bfd12ed77 --- /dev/null +++ b/migrations/versions/e517276bb1c2_users_table.py @@ -0,0 +1,42 @@ +"""users table + +Revision ID: e517276bb1c2 +Revises: +Create Date: 2017-09-11 11:23:05.566844 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e517276bb1c2' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password_hash', sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True) + batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_username')) + batch_op.drop_index(batch_op.f('ix_user_email')) + + op.drop_table('user') + # ### end Alembic commands ### diff --git a/migrations/versions/f7ac3d27bb1d_notifications.py b/migrations/versions/f7ac3d27bb1d_notifications.py new file mode 100644 index 000000000..a7dd48906 --- /dev/null +++ b/migrations/versions/f7ac3d27bb1d_notifications.py @@ -0,0 +1,46 @@ +"""notifications + +Revision ID: f7ac3d27bb1d +Revises: d049de007ccf +Create Date: 2017-11-22 19:48:39.945858 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f7ac3d27bb1d' +down_revision = 'd049de007ccf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('notification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.Float(), nullable=False), + sa.Column('payload_json', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + with op.batch_alter_table('notification', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_notification_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_notification_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_notification_user_id'), ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('notification', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_notification_user_id')) + batch_op.drop_index(batch_op.f('ix_notification_timestamp')) + batch_op.drop_index(batch_op.f('ix_notification_name')) + + op.drop_table('notification') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..748314dd9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +aiosmtpd==1.4.4.post2 +alembic==1.12.1 +atpublic==4.0 +attrs==23.1.0 +Babel==2.13.1 +blinker==1.7.0 +certifi==2023.11.17 +charset-normalizer==3.3.2 +click==8.1.7 +dnspython==2.4.2 +elastic-transport==8.10.0 +elasticsearch==8.11.0 +email-validator==2.1.0.post1 +Flask==3.0.0 +flask-babel==4.0.0 +Flask-Login==0.6.3 +Flask-Mail==0.9.1 +Flask-Migrate==4.0.5 +Flask-Moment==1.0.5 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.1 +greenlet==3.0.1 +idna==3.4 +itsdangerous==2.1.2 +Jinja2==3.1.2 +langdetect==1.0.9 +Mako==1.3.0 +MarkupSafe==2.1.3 +packaging==23.2 +PyJWT==2.8.0 +python-dotenv==1.0.0 +pytz==2023.3.post1 +requests==2.31.0 +setuptools==68.2.2 +six==1.16.0 +SQLAlchemy==2.0.23 +typing_extensions==4.8.0 +urllib3==2.1.0 +Werkzeug==3.0.1 +WTForms==3.1.1 diff --git a/tests.py b/tests.py new file mode 100755 index 000000000..4ef909857 --- /dev/null +++ b/tests.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +from datetime import datetime, timezone, timedelta +import unittest +from app import create_app, db +from app.models import User, Post +from config import Config + + +class TestConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite://' + ELASTICSEARCH_URL = None + + +class UserModelCase(unittest.TestCase): + def setUp(self): + self.app = create_app(TestConfig) + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_password_hashing(self): + u = User(username='susan', email='susan@example.com') + u.set_password('cat') + self.assertFalse(u.check_password('dog')) + self.assertTrue(u.check_password('cat')) + + def test_avatar(self): + u = User(username='john', email='john@example.com') + self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/' + 'd4c74594d841139328695756648b6bd6' + '?d=identicon&s=128')) + + def test_follow(self): + u1 = User(username='john', email='john@example.com') + u2 = User(username='susan', email='susan@example.com') + db.session.add(u1) + db.session.add(u2) + db.session.commit() + following = db.session.scalars(u1.following.select()).all() + followers = db.session.scalars(u2.followers.select()).all() + self.assertEqual(following, []) + self.assertEqual(followers, []) + + u1.follow(u2) + db.session.commit() + self.assertTrue(u1.is_following(u2)) + self.assertEqual(u1.following_count(), 1) + self.assertEqual(u2.followers_count(), 1) + u1_following = db.session.scalars(u1.following.select()).all() + u2_followers = db.session.scalars(u2.followers.select()).all() + self.assertEqual(u1_following[0].username, 'susan') + self.assertEqual(u2_followers[0].username, 'john') + + u1.unfollow(u2) + db.session.commit() + self.assertFalse(u1.is_following(u2)) + self.assertEqual(u1.following_count(), 0) + self.assertEqual(u2.followers_count(), 0) + + def test_follow_posts(self): + # create four users + u1 = User(username='john', email='john@example.com') + u2 = User(username='susan', email='susan@example.com') + u3 = User(username='mary', email='mary@example.com') + u4 = User(username='david', email='david@example.com') + db.session.add_all([u1, u2, u3, u4]) + + # create four posts + now = datetime.now(timezone.utc) + p1 = Post(body="post from john", author=u1, + timestamp=now + timedelta(seconds=1)) + p2 = Post(body="post from susan", author=u2, + timestamp=now + timedelta(seconds=4)) + p3 = Post(body="post from mary", author=u3, + timestamp=now + timedelta(seconds=3)) + p4 = Post(body="post from david", author=u4, + timestamp=now + timedelta(seconds=2)) + db.session.add_all([p1, p2, p3, p4]) + db.session.commit() + + # setup the followers + u1.follow(u2) # john follows susan + u1.follow(u4) # john follows david + u2.follow(u3) # susan follows mary + u3.follow(u4) # mary follows david + db.session.commit() + + # check the following posts of each user + f1 = db.session.scalars(u1.following_posts()).all() + f2 = db.session.scalars(u2.following_posts()).all() + f3 = db.session.scalars(u3.following_posts()).all() + f4 = db.session.scalars(u4.following_posts()).all() + self.assertEqual(f1, [p2, p4, p1]) + self.assertEqual(f2, [p2, p3]) + self.assertEqual(f3, [p3, p4]) + self.assertEqual(f4, [p4]) + + +if __name__ == '__main__': + unittest.main(verbosity=2)