From 5d4559227a7f215251ed1dcd15f884443109e153 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Mon, 4 Sep 2017 23:28:11 -0700 Subject: [PATCH 01/21] Chapter 2: Templates (v0.2) --- app/routes.py | 14 +++++++++++++- app/templates/base.html | 15 +++++++++++++++ app/templates/index.html | 8 ++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html diff --git a/app/routes.py b/app/routes.py index 69701e322..d486cc3de 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,7 +1,19 @@ +from flask import render_template from app import app @app.route('/') @app.route('/index') def index(): - return "Hello, World!" + user = {'username': 'Miguel'} + posts = [ + { + 'author': {'username': 'John'}, + 'body': 'Beautiful day in Portland!' + }, + { + 'author': {'username': 'Susan'}, + 'body': 'The Avengers movie was so cool!' + } + ] + return render_template('index.html', title='Home', user=user, posts=posts) diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 000000000..56e87bef5 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,15 @@ + + + + {% if title %} + {{ title }} - Microblog + {% else %} + Welcome to Microblog + {% endif %} + + +
Microblog: Home
+
+ {% block content %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 000000000..b580e8c66 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +

Hi, {{ user.username }}!

+ {% for post in posts %} +

{{ post.author.username }} says: {{ post.body }}

+ {% endfor %} +{% endblock %} From 04dfcfbe58aa63a3574c1ce5dc0b9db223afa1fa Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 5 Sep 2017 00:04:56 -0700 Subject: [PATCH 02/21] Chapter 3: Web Forms (v0.3) --- app/__init__.py | 2 ++ app/forms.py | 10 ++++++++++ app/routes.py | 13 ++++++++++++- app/templates/base.html | 15 ++++++++++++++- app/templates/login.html | 24 ++++++++++++++++++++++++ config.py | 4 ++++ 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 app/forms.py create mode 100644 app/templates/login.html create mode 100644 config.py diff --git a/app/__init__.py b/app/__init__.py index 96c8ef5dc..f5b34f45b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,7 @@ from flask import Flask +from config import Config app = Flask(__name__) +app.config.from_object(Config) from app import routes diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 000000000..6d1003a67 --- /dev/null +++ b/app/forms.py @@ -0,0 +1,10 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired + + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember Me') + submit = SubmitField('Sign In') diff --git a/app/routes.py b/app/routes.py index d486cc3de..82bfa5def 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,5 +1,6 @@ -from flask import render_template +from flask import render_template, flash, redirect, url_for from app import app +from app.forms import LoginForm @app.route('/') @@ -17,3 +18,13 @@ def index(): } ] return render_template('index.html', title='Home', user=user, posts=posts) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + flash('Login requested for user {}, remember_me={}'.format( + form.username.data, form.remember_me.data)) + return redirect(url_for('index')) + return render_template('login.html', title='Sign In', form=form) diff --git a/app/templates/base.html b/app/templates/base.html index 56e87bef5..cb17e618b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,8 +8,21 @@ {% endif %} -
Microblog: Home
+
+ Microblog: + Home + Login +

+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} {% block content %}{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 000000000..806e09a6a --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block content %} +

Sign In

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.remember_me() }} {{ form.remember_me.label }}

+

{{ form.submit() }}

+
+{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 000000000..3f9f12138 --- /dev/null +++ b/config.py @@ -0,0 +1,4 @@ +import os + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' From d86cf37713471ea29853d4210a5fd5808fbfee78 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Mon, 11 Sep 2017 14:30:21 -0700 Subject: [PATCH 03/21] Chapter 4: Database (v0.4) --- app/__init__.py | 6 +- app/models.py | 34 ++++++++ config.py | 4 + microblog.py | 10 ++- migrations/README | 1 + migrations/alembic.ini | 45 ++++++++++ migrations/env.py | 87 +++++++++++++++++++ migrations/script.py.mako | 24 +++++ .../versions/780739b227a7_posts_table.py | 43 +++++++++ .../versions/e517276bb1c2_users_table.py | 42 +++++++++ 10 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 app/models.py create mode 100755 migrations/README create mode 100644 migrations/alembic.ini create mode 100755 migrations/env.py create mode 100755 migrations/script.py.mako create mode 100644 migrations/versions/780739b227a7_posts_table.py create mode 100644 migrations/versions/e517276bb1c2_users_table.py diff --git a/app/__init__.py b/app/__init__.py index f5b34f45b..612a97254 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,11 @@ from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate from config import Config app = Flask(__name__) app.config.from_object(Config) +db = SQLAlchemy(app) +migrate = Migrate(app, db) -from app import routes +from app import routes, models diff --git a/app/models.py b/app/models.py new file mode 100644 index 000000000..710c61992 --- /dev/null +++ b/app/models.py @@ -0,0 +1,34 @@ +from datetime import datetime, timezone +from typing import Optional +import sqlalchemy as sa +import sqlalchemy.orm as so +from app import db + + +class User(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)) + + posts: so.WriteOnlyMapped['Post'] = so.relationship( + back_populates='author') + + def __repr__(self): + return ''.format(self.username) + + +class Post(db.Model): + 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) + + author: so.Mapped[User] = so.relationship(back_populates='posts') + + def __repr__(self): + return ''.format(self.body) diff --git a/config.py b/config.py index 3f9f12138..7f265d175 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,8 @@ import os +basedir = os.path.abspath(os.path.dirname(__file__)) + class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'app.db') diff --git a/microblog.py b/microblog.py index d099b92db..34798e5ba 100644 --- a/microblog.py +++ b/microblog.py @@ -1 +1,9 @@ -from app import app +import sqlalchemy as sa +import sqlalchemy.orm as so +from app import app, db +from app.models import User, Post + + +@app.shell_context_processor +def make_shell_context(): + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post} 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/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/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 ### From 854708a5f3c55e40e07013fe11b7cae9642e1593 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 12 Sep 2017 23:31:39 -0700 Subject: [PATCH 04/21] Chapter 5: User Logins (v0.5) --- app/__init__.py | 3 +++ app/forms.py | 26 ++++++++++++++++++- app/models.py | 17 +++++++++++-- app/routes.py | 50 +++++++++++++++++++++++++++++++------ app/templates/base.html | 4 +++ app/templates/index.html | 2 +- app/templates/login.html | 1 + app/templates/register.html | 37 +++++++++++++++++++++++++++ 8 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 app/templates/register.html diff --git a/app/__init__.py b/app/__init__.py index 612a97254..7366fefb5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,11 +1,14 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate +from flask_login import LoginManager from config import Config app = Flask(__name__) app.config.from_object(Config) db = SQLAlchemy(app) migrate = Migrate(app, db) +login = LoginManager(app) +login.login_view = 'login' from app import routes, models diff --git a/app/forms.py b/app/forms.py index 6d1003a67..018974238 100644 --- a/app/forms.py +++ b/app/forms.py @@ -1,6 +1,9 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import DataRequired +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo +import sqlalchemy as sa +from app import db +from app.models import User class LoginForm(FlaskForm): @@ -8,3 +11,24 @@ class LoginForm(FlaskForm): password = PasswordField('Password', validators=[DataRequired()]) remember_me = BooleanField('Remember Me') submit = SubmitField('Sign In') + + +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + password2 = PasswordField( + 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('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.') diff --git a/app/models.py b/app/models.py index 710c61992..1448a0e78 100644 --- a/app/models.py +++ b/app/models.py @@ -2,10 +2,12 @@ from typing import Optional import sqlalchemy as sa import sqlalchemy.orm as so -from app import db +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from app import db, login -class User(db.Model): +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) @@ -19,6 +21,17 @@ class User(db.Model): 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) + + +@login.user_loader +def load_user(id): + return db.session.get(User, int(id)) + class Post(db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) diff --git a/app/routes.py b/app/routes.py index 82bfa5def..239519104 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,12 +1,16 @@ -from flask import render_template, flash, redirect, url_for -from app import app -from app.forms import LoginForm +from urllib.parse import urlsplit +from flask import render_template, flash, redirect, url_for, request +from flask_login import login_user, logout_user, current_user, login_required +import sqlalchemy as sa +from app import app, db +from app.forms import LoginForm, RegistrationForm +from app.models import User @app.route('/') @app.route('/index') +@login_required def index(): - user = {'username': 'Miguel'} posts = [ { 'author': {'username': 'John'}, @@ -17,14 +21,44 @@ def index(): 'body': 'The Avengers movie was so cool!' } ] - return render_template('index.html', title='Home', user=user, posts=posts) + return render_template('index.html', title='Home', posts=posts) @app.route('/login', methods=['GET', 'POST']) def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) form = LoginForm() if form.validate_on_submit(): - flash('Login requested for user {}, remember_me={}'.format( - form.username.data, form.remember_me.data)) + 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('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('index') + return redirect(next_page) + return render_template('login.html', title='Sign In', form=form) + + +@app.route('/logout') +def logout(): + logout_user() + return redirect(url_for('index')) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: return redirect(url_for('index')) - return render_template('login.html', title='Sign In', form=form) + 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('login')) + return render_template('register.html', title='Register', form=form) diff --git a/app/templates/base.html b/app/templates/base.html index cb17e618b..496ea6de2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,7 +11,11 @@
Microblog: Home + {% if current_user.is_anonymous %} Login + {% else %} + Logout + {% endif %}

{% with messages = get_flashed_messages() %} diff --git a/app/templates/index.html b/app/templates/index.html index b580e8c66..2565a019a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} -

Hi, {{ user.username }}!

+

Hi, {{ current_user.username }}!

{% for post in posts %}

{{ post.author.username }} says: {{ post.body }}

{% endfor %} diff --git a/app/templates/login.html b/app/templates/login.html index 806e09a6a..28752937a 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -21,4 +21,5 @@

Sign In

{{ form.remember_me() }} {{ form.remember_me.label }}

{{ form.submit() }}

+

New User? Click to Register!

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

Register

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.email.label }}
+ {{ form.email(size=64) }}
+ {% for error in form.email.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password2.label }}
+ {{ form.password2(size=32) }}
+ {% for error in form.password2.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+{% endblock %} From 5ce31603d8ff33795e4e3ca36aa075088aa46942 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 14 Sep 2017 10:48:56 -0700 Subject: [PATCH 05/21] Chapter 6: Profile Page and Avatars (v0.6) --- app/forms.py | 12 +++++- app/models.py | 8 ++++ app/routes.py | 38 ++++++++++++++++++- app/templates/_post.html | 6 +++ app/templates/base.html | 1 + app/templates/edit_profile.html | 23 +++++++++++ app/templates/user.html | 21 ++++++++++ .../37f06a334dbf_new_fields_in_user_model.py | 34 +++++++++++++++++ 8 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 app/templates/_post.html create mode 100644 app/templates/edit_profile.html create mode 100644 app/templates/user.html create mode 100644 migrations/versions/37f06a334dbf_new_fields_in_user_model.py diff --git a/app/forms.py b/app/forms.py index 018974238..977fce779 100644 --- a/app/forms.py +++ b/app/forms.py @@ -1,6 +1,8 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo +from wtforms import StringField, PasswordField, BooleanField, SubmitField, \ + TextAreaField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, \ + Length import sqlalchemy as sa from app import db from app.models import User @@ -32,3 +34,9 @@ def validate_email(self, email): User.email == email.data)) if user is not None: raise ValidationError('Please use a different email address.') + + +class EditProfileForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) + submit = SubmitField('Submit') diff --git a/app/models.py b/app/models.py index 1448a0e78..9ac907ea1 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from hashlib import md5 from typing import Optional import sqlalchemy as sa import sqlalchemy.orm as so @@ -14,6 +15,9 @@ class User(UserMixin, db.Model): 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)) posts: so.WriteOnlyMapped['Post'] = so.relationship( back_populates='author') @@ -27,6 +31,10 @@ def set_password(self, 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}' + @login.user_loader def load_user(id): diff --git a/app/routes.py b/app/routes.py index 239519104..0f2d3906b 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,12 +1,20 @@ +from datetime import datetime, timezone from urllib.parse import urlsplit from flask import render_template, flash, redirect, url_for, request from flask_login import login_user, logout_user, current_user, login_required import sqlalchemy as sa from app import app, db -from app.forms import LoginForm, RegistrationForm +from app.forms import LoginForm, RegistrationForm, EditProfileForm from app.models import User +@app.before_request +def before_request(): + if current_user.is_authenticated: + current_user.last_seen = datetime.now(timezone.utc) + db.session.commit() + + @app.route('/') @app.route('/index') @login_required @@ -62,3 +70,31 @@ def register(): flash('Congratulations, you are now a registered user!') return redirect(url_for('login')) return render_template('register.html', title='Register', form=form) + + +@app.route('/user/') +@login_required +def user(username): + user = db.first_or_404(sa.select(User).where(User.username == username)) + posts = [ + {'author': user, 'body': 'Test post #1'}, + {'author': user, 'body': 'Test post #2'} + ] + return render_template('user.html', user=user, posts=posts) + + +@app.route('/edit_profile', methods=['GET', 'POST']) +@login_required +def edit_profile(): + form = EditProfileForm() + 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('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) diff --git a/app/templates/_post.html b/app/templates/_post.html new file mode 100644 index 000000000..d020426d8 --- /dev/null +++ b/app/templates/_post.html @@ -0,0 +1,6 @@ + + + + + +
{{ post.author.username }} says:
{{ post.body }}
diff --git a/app/templates/base.html b/app/templates/base.html index 496ea6de2..f787db171 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,6 +14,7 @@ {% if current_user.is_anonymous %} Login {% else %} + Profile Logout {% endif %} diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html new file mode 100644 index 000000000..e2471acb1 --- /dev/null +++ b/app/templates/edit_profile.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block content %} +

Edit Profile

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.about_me.label }}
+ {{ form.about_me(cols=50, rows=4) }}
+ {% for error in form.about_me.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

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

User: {{ user.username }}

+ {% if user.about_me %}

{{ user.about_me }}

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

Last seen on: {{ user.last_seen }}

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

Edit your profile

+ {% endif %} +
+
+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} +{% endblock %} 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 ### From 6fd98255a6dc4efb5cd44781302e5a6afc3d6fd7 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Fri, 15 Sep 2017 00:03:20 -0700 Subject: [PATCH 06/21] Chapter 7: Error Handling (v0.7) --- .flaskenv | 1 + app/__init__.py | 33 ++++++++++++++++++++++++++++++++- app/errors.py | 13 +++++++++++++ app/forms.py | 11 +++++++++++ app/routes.py | 2 +- app/templates/404.html | 6 ++++++ app/templates/500.html | 7 +++++++ config.py | 6 ++++++ 8 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 app/errors.py create mode 100644 app/templates/404.html create mode 100644 app/templates/500.html 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/app/__init__.py b/app/__init__.py index 7366fefb5..c71797262 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,6 @@ +import logging +from logging.handlers import SMTPHandler, RotatingFileHandler +import os from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate @@ -11,4 +14,32 @@ login = LoginManager(app) login.login_view = 'login' -from app import routes, models +if not app.debug: + 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 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') + +from app import routes, models, errors diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 000000000..ed214c473 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,13 @@ +from flask import render_template +from app import app, db + + +@app.errorhandler(404) +def not_found_error(error): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('500.html'), 500 diff --git a/app/forms.py b/app/forms.py index 977fce779..53bc7d5b0 100644 --- a/app/forms.py +++ b/app/forms.py @@ -40,3 +40,14 @@ class EditProfileForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) submit = SubmitField('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.') diff --git a/app/routes.py b/app/routes.py index 0f2d3906b..06d3caa3c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -86,7 +86,7 @@ def user(username): @app.route('/edit_profile', methods=['GET', 'POST']) @login_required def edit_profile(): - form = EditProfileForm() + form = EditProfileForm(current_user.username) if form.validate_on_submit(): current_user.username = form.username.data current_user.about_me = form.about_me.data diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 000000000..230882056 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block content %} +

Not Found

+

Back

+{% endblock %} diff --git a/app/templates/500.html b/app/templates/500.html new file mode 100644 index 000000000..660c3a198 --- /dev/null +++ b/app/templates/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/config.py b/config.py index 7f265d175..60950ce89 100644 --- a/config.py +++ b/config.py @@ -6,3 +6,9 @@ class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 'sqlite:///' + os.path.join(basedir, 'app.db') + 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'] From 9e91e3d4d5cb4a6d17fb48bcaf883768f86f9b4d Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sat, 16 Sep 2017 00:05:17 -0700 Subject: [PATCH 07/21] Chapter 8: Followers (v0.8) --- app/forms.py | 4 + app/models.py | 55 ++++++++++ app/routes.py | 47 +++++++- app/templates/user.html | 15 +++ migrations/versions/ae346256b650_followers.py | 34 ++++++ tests.py | 100 ++++++++++++++++++ 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/ae346256b650_followers.py create mode 100755 tests.py diff --git a/app/forms.py b/app/forms.py index 53bc7d5b0..99ac8dab6 100644 --- a/app/forms.py +++ b/app/forms.py @@ -51,3 +51,7 @@ def validate_username(self, username): User.username == self.username.data)) if user is not None: raise ValidationError('Please use a different username.') + + +class EmptyForm(FlaskForm): + submit = SubmitField('Submit') diff --git a/app/models.py b/app/models.py index 9ac907ea1..8a7bf6b2a 100644 --- a/app/models.py +++ b/app/models.py @@ -8,6 +8,16 @@ from app import db, login +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, @@ -21,6 +31,14 @@ class User(UserMixin, db.Model): 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') def __repr__(self): return ''.format(self.username) @@ -35,6 +53,43 @@ 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()) + ) + @login.user_loader def load_user(id): diff --git a/app/routes.py b/app/routes.py index 06d3caa3c..4d7f8217a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,7 +4,7 @@ from flask_login import login_user, logout_user, current_user, login_required import sqlalchemy as sa from app import app, db -from app.forms import LoginForm, RegistrationForm, EditProfileForm +from app.forms import LoginForm, RegistrationForm, EditProfileForm, EmptyForm from app.models import User @@ -80,7 +80,8 @@ def user(username): {'author': user, 'body': 'Test post #1'}, {'author': user, 'body': 'Test post #2'} ] - return render_template('user.html', user=user, posts=posts) + form = EmptyForm() + return render_template('user.html', user=user, posts=posts, form=form) @app.route('/edit_profile', methods=['GET', 'POST']) @@ -98,3 +99,45 @@ def edit_profile(): form.about_me.data = current_user.about_me return render_template('edit_profile.html', title='Edit Profile', form=form) + + +@app.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(f'User {username} not found.') + return redirect(url_for('index')) + if user == current_user: + flash('You cannot follow yourself!') + return redirect(url_for('user', username=username)) + current_user.follow(user) + db.session.commit() + flash(f'You are following {username}!') + return redirect(url_for('user', username=username)) + else: + return redirect(url_for('index')) + + +@app.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(f'User {username} not found.') + return redirect(url_for('index')) + if user == current_user: + flash('You cannot unfollow yourself!') + return redirect(url_for('user', username=username)) + current_user.unfollow(user) + db.session.commit() + flash(f'You are not following {username}.') + return redirect(url_for('user', username=username)) + else: + return redirect(url_for('index')) diff --git a/app/templates/user.html b/app/templates/user.html index 5bacb4ea6..25d6546b9 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -8,8 +8,23 @@

User: {{ user.username }}

{% if user.about_me %}

{{ user.about_me }}

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

Last seen on: {{ user.last_seen }}

{% endif %} +

{{ user.followers_count() }} followers, {{ user.following_count() }} following.

{% if user == current_user %}

Edit your profile

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

+

+ {{ form.hidden_tag() }} + {{ form.submit(value='Follow') }} +
+

+ {% else %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value='Unfollow') }} +
+

{% endif %} 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/tests.py b/tests.py new file mode 100755 index 000000000..e45a96142 --- /dev/null +++ b/tests.py @@ -0,0 +1,100 @@ +import os +os.environ['DATABASE_URL'] = 'sqlite://' + +from datetime import datetime, timezone, timedelta +import unittest +from app import app, db +from app.models import User, Post + + +class UserModelCase(unittest.TestCase): + def setUp(self): + self.app_context = 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) From e5cb085d5162023e6c404aa1a81d9722438a41f2 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 17 Sep 2017 22:41:40 -0700 Subject: [PATCH 08/21] Chapter 9: Pagination (v0.9) --- app/forms.py | 6 ++++ app/routes.py | 68 ++++++++++++++++++++++++++++------------ app/templates/_post.html | 2 +- app/templates/base.html | 3 +- app/templates/index.html | 21 ++++++++++++- app/templates/user.html | 6 ++++ config.py | 1 + 7 files changed, 84 insertions(+), 23 deletions(-) diff --git a/app/forms.py b/app/forms.py index 99ac8dab6..9b41c2912 100644 --- a/app/forms.py +++ b/app/forms.py @@ -55,3 +55,9 @@ def validate_username(self, username): class EmptyForm(FlaskForm): submit = SubmitField('Submit') + + +class PostForm(FlaskForm): + post = TextAreaField('Say something', validators=[ + DataRequired(), Length(min=1, max=140)]) + submit = SubmitField('Submit') diff --git a/app/routes.py b/app/routes.py index 4d7f8217a..043c48beb 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,8 +4,9 @@ from flask_login import login_user, logout_user, current_user, login_required import sqlalchemy as sa from app import app, db -from app.forms import LoginForm, RegistrationForm, EditProfileForm, EmptyForm -from app.models import User +from app.forms import LoginForm, RegistrationForm, EditProfileForm, \ + EmptyForm, PostForm +from app.models import User, Post @app.before_request @@ -15,21 +16,42 @@ def before_request(): db.session.commit() -@app.route('/') -@app.route('/index') +@app.route('/', methods=['GET', 'POST']) +@app.route('/index', methods=['GET', 'POST']) @login_required def index(): - posts = [ - { - 'author': {'username': 'John'}, - 'body': 'Beautiful day in Portland!' - }, - { - 'author': {'username': 'Susan'}, - 'body': 'The Avengers movie was so cool!' - } - ] - return render_template('index.html', title='Home', posts=posts) + form = PostForm() + if form.validate_on_submit(): + post = Post(body=form.post.data, author=current_user) + db.session.add(post) + db.session.commit() + flash('Your post is now live!') + return redirect(url_for('index')) + page = request.args.get('page', 1, type=int) + posts = db.paginate(current_user.following_posts(), page=page, + per_page=app.config['POSTS_PER_PAGE'], error_out=False) + next_url = url_for('index', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('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) + + +@app.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=app.config['POSTS_PER_PAGE'], error_out=False) + next_url = url_for('explore', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('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) @app.route('/login', methods=['GET', 'POST']) @@ -76,12 +98,18 @@ def register(): @login_required def user(username): user = db.first_or_404(sa.select(User).where(User.username == username)) - posts = [ - {'author': user, 'body': 'Test post #1'}, - {'author': user, 'body': 'Test post #2'} - ] + 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=app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('user', username=user.username, page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('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, form=form) + return render_template('user.html', user=user, posts=posts.items, + next_url=next_url, prev_url=prev_url, form=form) @app.route('/edit_profile', methods=['GET', 'POST']) diff --git a/app/templates/_post.html b/app/templates/_post.html index d020426d8..a705b311f 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -1,6 +1,6 @@ - +
{{ post.author.username }} says:
{{ post.body }}
{{ post.author.username }} says:
{{ post.body }}
diff --git a/app/templates/base.html b/app/templates/base.html index f787db171..bd9f6e1d7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,6 +11,7 @@
Microblog: Home + Explore {% if current_user.is_anonymous %} Login {% else %} @@ -29,5 +30,5 @@ {% endif %} {% endwith %} {% block content %}{% endblock %} - + diff --git a/app/templates/index.html b/app/templates/index.html index 2565a019a..eb3f876a3 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,7 +2,26 @@ {% block content %}

Hi, {{ current_user.username }}!

+ {% if form %} +
+ {{ form.hidden_tag() }} +

+ {{ form.post.label }}
+ {{ form.post(cols=32, rows=4) }}
+ {% for error in form.post.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+ {% endif %} {% for post in posts %} -

{{ post.author.username }} says: {{ post.body }}

+ {% include '_post.html' %} {% endfor %} + {% if prev_url %} + Newer posts + {% endif %} + {% if next_url %} + Older posts + {% endif %} {% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html index 25d6546b9..80e46ab13 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -33,4 +33,10 @@

User: {{ user.username }}

{% for post in posts %} {% include '_post.html' %} {% endfor %} + {% if prev_url %} + Newer posts + {% endif %} + {% if next_url %} + Older posts + {% endif %} {% endblock %} diff --git a/config.py b/config.py index 60950ce89..c330da370 100644 --- a/config.py +++ b/config.py @@ -12,3 +12,4 @@ class Config: MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') ADMINS = ['your-email@example.com'] + POSTS_PER_PAGE = 25 From 5dcde16c5850310fca819578779d15a81281c7bb Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 26 Sep 2017 00:16:46 -0700 Subject: [PATCH 09/21] Chapter 10: Email Support (v0.10) --- app/__init__.py | 2 ++ app/email.py | 27 +++++++++++++++++ app/forms.py | 12 ++++++++ app/models.py | 18 +++++++++++- app/routes.py | 35 ++++++++++++++++++++++- app/templates/email/reset_password.html | 17 +++++++++++ app/templates/email/reset_password.txt | 11 +++++++ app/templates/login.html | 4 +++ app/templates/reset_password.html | 23 +++++++++++++++ app/templates/reset_password_request.html | 16 +++++++++++ 10 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 app/email.py create mode 100644 app/templates/email/reset_password.html create mode 100644 app/templates/email/reset_password.txt create mode 100644 app/templates/reset_password.html create mode 100644 app/templates/reset_password_request.html diff --git a/app/__init__.py b/app/__init__.py index c71797262..95f8c6c22 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,6 +5,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager +from flask_mail import Mail from config import Config app = Flask(__name__) @@ -13,6 +14,7 @@ migrate = Migrate(app, db) login = LoginManager(app) login.login_view = 'login' +mail = Mail(app) if not app.debug: if app.config['MAIL_SERVER']: diff --git a/app/email.py b/app/email.py new file mode 100644 index 000000000..fc7f92968 --- /dev/null +++ b/app/email.py @@ -0,0 +1,27 @@ +from threading import Thread +from flask import render_template +from flask_mail import Message +from app import app, 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=(app, msg)).start() + + +def send_password_reset_email(user): + token = user.get_reset_password_token() + send_email('[Microblog] Reset Your Password', + sender=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/forms.py b/app/forms.py index 9b41c2912..e5df0d2ae 100644 --- a/app/forms.py +++ b/app/forms.py @@ -36,6 +36,18 @@ def validate_email(self, email): raise ValidationError('Please use a different email address.') +class ResetPasswordRequestForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + submit = SubmitField('Request Password Reset') + + +class ResetPasswordForm(FlaskForm): + password = PasswordField('Password', validators=[DataRequired()]) + password2 = PasswordField( + 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Request Password Reset') + + class EditProfileForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) diff --git a/app/models.py b/app/models.py index 8a7bf6b2a..5bf39b9cc 100644 --- a/app/models.py +++ b/app/models.py @@ -1,11 +1,13 @@ from datetime import datetime, timezone from hashlib import md5 +from time import time from typing import Optional import sqlalchemy as sa import sqlalchemy.orm as so from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash -from app import db, login +import jwt +from app import app, db, login followers = sa.Table( @@ -90,6 +92,20 @@ def following_posts(self): .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}, + app.config['SECRET_KEY'], algorithm='HS256') + + @staticmethod + def verify_reset_password_token(token): + try: + id = jwt.decode(token, app.config['SECRET_KEY'], + algorithms=['HS256'])['reset_password'] + except Exception: + return + return db.session.get(User, id) + @login.user_loader def load_user(id): diff --git a/app/routes.py b/app/routes.py index 043c48beb..6e97e98e0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -5,8 +5,9 @@ import sqlalchemy as sa from app import app, db from app.forms import LoginForm, RegistrationForm, EditProfileForm, \ - EmptyForm, PostForm + EmptyForm, PostForm, ResetPasswordRequestForm, ResetPasswordForm from app.models import User, Post +from app.email import send_password_reset_email @app.before_request @@ -94,6 +95,38 @@ def register(): return render_template('register.html', title='Register', form=form) +@app.route('/reset_password_request', methods=['GET', 'POST']) +def reset_password_request(): + if current_user.is_authenticated: + return redirect(url_for('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('login')) + return render_template('reset_password_request.html', + title='Reset Password', form=form) + + +@app.route('/reset_password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('index')) + user = User.verify_reset_password_token(token) + if not user: + return redirect(url_for('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('login')) + return render_template('reset_password.html', form=form) + + @app.route('/user/') @login_required def user(username): diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html new file mode 100644 index 000000000..6fa09feaf --- /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('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..53873471f --- /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('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/login.html b/app/templates/login.html index 28752937a..2a54187c4 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -22,4 +22,8 @@

Sign In

{{ form.submit() }}

New User? Click to Register!

+

+ Forgot Your Password? + Click to Reset It +

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

Reset Your Password

+
+ {{ form.hidden_tag() }} +

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password2.label }}
+ {{ form.password2(size=32) }}
+ {% for error in form.password2.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

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

Reset Password

+
+ {{ form.hidden_tag() }} +

+ {{ form.email.label }}
+ {{ form.email(size=64) }}
+ {% for error in form.email.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+{% endblock %} From bfa551be3ba92b854b39def929b60928605fd65a Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 26 Sep 2017 23:43:28 -0700 Subject: [PATCH 10/21] Chapter 11: Facelift (v0.11) --- app/templates/_post.html | 19 +++-- app/templates/base.html | 86 ++++++++++++++++------- app/templates/bootstrap_wtf.html | 70 ++++++++++++++++++ app/templates/edit_profile.html | 20 +----- app/templates/index.html | 33 +++++---- app/templates/login.html | 23 +----- app/templates/register.html | 34 +-------- app/templates/reset_password.html | 20 +----- app/templates/reset_password_request.html | 13 +--- app/templates/user.html | 31 ++++---- 10 files changed, 190 insertions(+), 159 deletions(-) create mode 100644 app/templates/bootstrap_wtf.html diff --git a/app/templates/_post.html b/app/templates/_post.html index a705b311f..29caf6f1c 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -1,6 +1,17 @@ - - - - +
{{ post.author.username }} says:
{{ post.body }}
+ + +
+ + + + + + {{ post.author.username }} + + says: +
+ {{ post.body }} +
diff --git a/app/templates/base.html b/app/templates/base.html index bd9f6e1d7..e08b1c9ee 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,34 +1,66 @@ - - - {% if title %} - {{ title }} - Microblog - {% else %} - Welcome to Microblog - {% endif %} - - -
- Microblog: - Home - Explore + + + + + {% 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 %} +
+ 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 index e2471acb1..ef7ca7532 100644 --- a/app/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -1,23 +1,7 @@ {% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} {% block content %}

Edit Profile

-
- {{ form.hidden_tag() }} -

- {{ form.username.label }}
- {{ form.username(size=32) }}
- {% for error in form.username.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.about_me.label }}
- {{ form.about_me(cols=50, rows=4) }}
- {% for error in form.about_me.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.submit() }}

-
+ {{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index eb3f876a3..b5b560eed 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,27 +1,26 @@ {% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} {% block content %}

Hi, {{ current_user.username }}!

{% if form %} -
- {{ form.hidden_tag() }} -

- {{ form.post.label }}
- {{ form.post(cols=32, rows=4) }}
- {% for error in form.post.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.submit() }}

-
+ {{ wtf.quick_form(form) }} {% endif %} {% for post in posts %} {% include '_post.html' %} {% endfor %} - {% if prev_url %} - Newer posts - {% endif %} - {% if next_url %} - Older posts - {% endif %} + {% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 2a54187c4..63439b553 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,26 +1,9 @@ -{% extends "base.html" %} +{% extends 'base.html' %} +{% import "bootstrap_wtf.html" as wtf %} {% block content %}

Sign In

-
- {{ form.hidden_tag() }} -

- {{ form.username.label }}
- {{ form.username(size=32) }}
- {% for error in form.username.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.password.label }}
- {{ form.password(size=32) }}
- {% for error in form.password.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.remember_me() }} {{ form.remember_me.label }}

-

{{ form.submit() }}

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

New User? Click to Register!

Forgot Your Password? diff --git a/app/templates/register.html b/app/templates/register.html index 37b7ac58d..26db86475 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -1,37 +1,7 @@ {% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} {% block content %}

Register

-
- {{ form.hidden_tag() }} -

- {{ form.username.label }}
- {{ form.username(size=32) }}
- {% for error in form.username.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.email.label }}
- {{ form.email(size=64) }}
- {% for error in form.email.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.password.label }}
- {{ form.password(size=32) }}
- {% for error in form.password.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.password2.label }}
- {{ form.password2(size=32) }}
- {% for error in form.password2.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.submit() }}

-
+ {{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/reset_password.html b/app/templates/reset_password.html index 92f0c8ef6..bd5ccc245 100644 --- a/app/templates/reset_password.html +++ b/app/templates/reset_password.html @@ -1,23 +1,7 @@ {% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} {% block content %}

Reset Your Password

-
- {{ form.hidden_tag() }} -

- {{ form.password.label }}
- {{ form.password(size=32) }}
- {% for error in form.password.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.password2.label }}
- {{ form.password2(size=32) }}
- {% for error in form.password2.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.submit() }}

-
+ {{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/reset_password_request.html b/app/templates/reset_password_request.html index 18bb0ae55..d60b1b531 100644 --- a/app/templates/reset_password_request.html +++ b/app/templates/reset_password_request.html @@ -1,16 +1,7 @@ {% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} {% block content %}

Reset Password

-
- {{ form.hidden_tag() }} -

- {{ form.email.label }}
- {{ form.email(size=64) }}
- {% for error in form.email.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.submit() }}

-
+ {{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html index 80e46ab13..5482076ab 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -1,9 +1,9 @@ {% extends "base.html" %} {% block content %} - - - +
+ +

User: {{ user.username }}

{% if user.about_me %}

{{ user.about_me }}

{% endif %} @@ -15,28 +15,35 @@

User: {{ user.username }}

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

{% else %}

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

{% endif %}
-
{% for post in posts %} {% include '_post.html' %} {% endfor %} - {% if prev_url %} - Newer posts - {% endif %} - {% if next_url %} - Older posts - {% endif %} + {% endblock %} From e48c71d8b20d9bc360543d0c764a19f16f0673e7 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 28 Sep 2017 22:28:20 -0700 Subject: [PATCH 11/21] Chapter 12: Dates and Times (v0.12) --- app/__init__.py | 2 ++ app/templates/_post.html | 2 +- app/templates/base.html | 1 + app/templates/user.html | 4 +++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 95f8c6c22..c52bcaa97 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,6 +6,7 @@ from flask_migrate import Migrate from flask_login import LoginManager from flask_mail import Mail +from flask_moment import Moment from config import Config app = Flask(__name__) @@ -15,6 +16,7 @@ login = LoginManager(app) login.login_view = 'login' mail = Mail(app) +moment = Moment(app) if not app.debug: if app.config['MAIL_SERVER']: diff --git a/app/templates/_post.html b/app/templates/_post.html index 29caf6f1c..b5b60220e 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -9,7 +9,7 @@ {{ post.author.username }} - says: + said {{ moment(post.timestamp).fromNow() }}:
{{ post.body }} diff --git a/app/templates/base.html b/app/templates/base.html index e08b1c9ee..bf7523e93 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -62,5 +62,6 @@ integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"> + {{ moment.include_moment() }} diff --git a/app/templates/user.html b/app/templates/user.html index 5482076ab..78120610d 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -7,7 +7,9 @@

User: {{ user.username }}

{% if user.about_me %}

{{ user.about_me }}

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

Last seen on: {{ user.last_seen }}

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

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

+ {% endif %}

{{ user.followers_count() }} followers, {{ user.following_count() }} following.

{% if user == current_user %}

Edit your profile

From da0ff57b301d7851b9297b6e4254d7bdb647fd4d Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sat, 30 Sep 2017 00:21:17 -0700 Subject: [PATCH 12/21] Chapter 13: I18n and L10n (v0.13) --- app/__init__.py | 11 +- app/cli.py | 38 +++ app/email.py | 3 +- app/forms.py | 48 ++-- app/routes.py | 44 ++-- app/templates/404.html | 4 +- app/templates/500.html | 6 +- app/templates/_post.html | 11 +- app/templates/base.html | 13 +- app/templates/edit_profile.html | 2 +- app/templates/index.html | 6 +- app/templates/login.html | 8 +- app/templates/register.html | 2 +- app/templates/reset_password.html | 2 +- app/templates/reset_password_request.html | 2 +- app/templates/user.html | 16 +- app/translations/es/LC_MESSAGES/messages.po | 259 ++++++++++++++++++++ babel.cfg | 2 + config.py | 1 + microblog.py | 2 +- 20 files changed, 401 insertions(+), 79 deletions(-) create mode 100644 app/cli.py create mode 100644 app/translations/es/LC_MESSAGES/messages.po create mode 100644 babel.cfg diff --git a/app/__init__.py b/app/__init__.py index c52bcaa97..3e278236d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,22 +1,30 @@ import logging from logging.handlers import SMTPHandler, RotatingFileHandler import os -from flask import Flask +from flask import Flask, request 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 config import Config + +def get_locale(): + return request.accept_languages.best_match(app.config['LANGUAGES']) + + app = Flask(__name__) app.config.from_object(Config) db = SQLAlchemy(app) migrate = Migrate(app, db) login = LoginManager(app) login.login_view = 'login' +login.login_message = _l('Please log in to access this page.') mail = Mail(app) moment = Moment(app) +babel = Babel(app, locale_selector=get_locale) if not app.debug: if app.config['MAIL_SERVER']: @@ -46,4 +54,5 @@ app.logger.setLevel(logging.INFO) app.logger.info('Microblog startup') + from app import routes, models, errors diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 000000000..8c6697ad0 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,38 @@ +import os +import click +from app import app + + +@app.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 index fc7f92968..1f4d46f75 100644 --- a/app/email.py +++ b/app/email.py @@ -1,6 +1,7 @@ from threading import Thread from flask import render_template from flask_mail import Message +from flask_babel import _ from app import app, mail @@ -18,7 +19,7 @@ def send_email(subject, sender, recipients, text_body, html_body): def send_password_reset_email(user): token = user.get_reset_password_token() - send_email('[Microblog] Reset Your Password', + send_email(_('[Microblog] Reset Your Password'), sender=app.config['ADMINS'][0], recipients=[user.email], text_body=render_template('email/reset_password.txt', diff --git a/app/forms.py b/app/forms.py index e5df0d2ae..1fc5fa548 100644 --- a/app/forms.py +++ b/app/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_babel import _, lazy_gettext as _l from wtforms import StringField, PasswordField, BooleanField, SubmitField, \ TextAreaField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, \ @@ -9,49 +10,52 @@ class LoginForm(FlaskForm): - username = StringField('Username', validators=[DataRequired()]) - password = PasswordField('Password', validators=[DataRequired()]) - remember_me = BooleanField('Remember Me') - submit = SubmitField('Sign In') + 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('Username', validators=[DataRequired()]) - email = StringField('Email', validators=[DataRequired(), Email()]) - password = PasswordField('Password', validators=[DataRequired()]) + username = StringField(_l('Username'), validators=[DataRequired()]) + email = StringField(_l('Email'), validators=[DataRequired(), Email()]) + password = PasswordField(_l('Password'), validators=[DataRequired()]) password2 = PasswordField( - 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) - submit = SubmitField('Register') + _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.') + 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.') + raise ValidationError(_('Please use a different email address.')) class ResetPasswordRequestForm(FlaskForm): - email = StringField('Email', validators=[DataRequired(), Email()]) - submit = SubmitField('Request Password Reset') + email = StringField(_l('Email'), validators=[DataRequired(), Email()]) + submit = SubmitField(_l('Request Password Reset')) class ResetPasswordForm(FlaskForm): - password = PasswordField('Password', validators=[DataRequired()]) + password = PasswordField(_l('Password'), validators=[DataRequired()]) password2 = PasswordField( - 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) - submit = SubmitField('Request Password Reset') + _l('Repeat Password'), validators=[DataRequired(), + EqualTo('password')]) + submit = SubmitField(_l('Request Password Reset')) class EditProfileForm(FlaskForm): - username = StringField('Username', validators=[DataRequired()]) - about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) - submit = SubmitField('Submit') + 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) @@ -62,7 +66,7 @@ def validate_username(self, 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.') + raise ValidationError(_('Please use a different username.')) class EmptyForm(FlaskForm): @@ -70,6 +74,6 @@ class EmptyForm(FlaskForm): class PostForm(FlaskForm): - post = TextAreaField('Say something', validators=[ + post = TextAreaField(_l('Say something'), validators=[ DataRequired(), Length(min=1, max=140)]) - submit = SubmitField('Submit') + submit = SubmitField(_l('Submit')) diff --git a/app/routes.py b/app/routes.py index 6e97e98e0..fe87a6387 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,7 +1,8 @@ from datetime import datetime, timezone from urllib.parse import urlsplit -from flask import render_template, flash, redirect, url_for, request +from flask import render_template, flash, redirect, url_for, request, g from flask_login import login_user, logout_user, current_user, login_required +from flask_babel import _, get_locale import sqlalchemy as sa from app import app, db from app.forms import LoginForm, RegistrationForm, EditProfileForm, \ @@ -15,6 +16,7 @@ def before_request(): if current_user.is_authenticated: current_user.last_seen = datetime.now(timezone.utc) db.session.commit() + g.locale = str(get_locale()) @app.route('/', methods=['GET', 'POST']) @@ -26,7 +28,7 @@ def index(): post = Post(body=form.post.data, author=current_user) db.session.add(post) db.session.commit() - flash('Your post is now live!') + flash(_('Your post is now live!')) return redirect(url_for('index')) page = request.args.get('page', 1, type=int) posts = db.paginate(current_user.following_posts(), page=page, @@ -35,7 +37,7 @@ def index(): if posts.has_next else None prev_url = url_for('index', page=posts.prev_num) \ if posts.has_prev else None - return render_template('index.html', title='Home', form=form, + return render_template('index.html', title=_('Home'), form=form, posts=posts.items, next_url=next_url, prev_url=prev_url) @@ -51,8 +53,9 @@ def explore(): if posts.has_next else None prev_url = url_for('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) + return render_template('index.html', title=_('Explore'), + posts=posts.items, next_url=next_url, + prev_url=prev_url) @app.route('/login', methods=['GET', 'POST']) @@ -64,14 +67,14 @@ def login(): 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') + flash(_('Invalid username or password')) return redirect(url_for('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('index') return redirect(next_page) - return render_template('login.html', title='Sign In', form=form) + return render_template('login.html', title=_('Sign In'), form=form) @app.route('/logout') @@ -90,9 +93,9 @@ def register(): user.set_password(form.password.data) db.session.add(user) db.session.commit() - flash('Congratulations, you are now a registered user!') + flash(_('Congratulations, you are now a registered user!')) return redirect(url_for('login')) - return render_template('register.html', title='Register', form=form) + return render_template('register.html', title=_('Register'), form=form) @app.route('/reset_password_request', methods=['GET', 'POST']) @@ -105,10 +108,11 @@ def reset_password_request(): 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') + flash( + _('Check your email for the instructions to reset your password')) return redirect(url_for('login')) return render_template('reset_password_request.html', - title='Reset Password', form=form) + title=_('Reset Password'), form=form) @app.route('/reset_password/', methods=['GET', 'POST']) @@ -122,7 +126,7 @@ def reset_password(token): if form.validate_on_submit(): user.set_password(form.password.data) db.session.commit() - flash('Your password has been reset.') + flash(_('Your password has been reset.')) return redirect(url_for('login')) return render_template('reset_password.html', form=form) @@ -153,12 +157,12 @@ def edit_profile(): current_user.username = form.username.data current_user.about_me = form.about_me.data db.session.commit() - flash('Your changes have been saved.') + flash(_('Your changes have been saved.')) return redirect(url_for('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', + return render_template('edit_profile.html', title=_('Edit Profile'), form=form) @@ -170,14 +174,14 @@ def follow(username): user = db.session.scalar( sa.select(User).where(User.username == username)) if user is None: - flash(f'User {username} not found.') + flash(_('User %(username)s not found.', username=username)) return redirect(url_for('index')) if user == current_user: - flash('You cannot follow yourself!') + flash(_('You cannot follow yourself!')) return redirect(url_for('user', username=username)) current_user.follow(user) db.session.commit() - flash(f'You are following {username}!') + flash(_('You are following %(username)s!', username=username)) return redirect(url_for('user', username=username)) else: return redirect(url_for('index')) @@ -191,14 +195,14 @@ def unfollow(username): user = db.session.scalar( sa.select(User).where(User.username == username)) if user is None: - flash(f'User {username} not found.') + flash(_('User %(username)s not found.', username=username)) return redirect(url_for('index')) if user == current_user: - flash('You cannot unfollow yourself!') + flash(_('You cannot unfollow yourself!')) return redirect(url_for('user', username=username)) current_user.unfollow(user) db.session.commit() - flash(f'You are not following {username}.') + flash(_('You are not following %(username)s.', username=username)) return redirect(url_for('user', username=username)) else: return redirect(url_for('index')) diff --git a/app/templates/404.html b/app/templates/404.html index 230882056..b68891e5e 100644 --- a/app/templates/404.html +++ b/app/templates/404.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -

Not Found

-

Back

+

{{ _('Not Found') }}

+

{{ _('Back') }}

{% endblock %} diff --git a/app/templates/500.html b/app/templates/500.html index 660c3a198..8d9ffd5f7 100644 --- a/app/templates/500.html +++ b/app/templates/500.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} -

An unexpected error has occurred

-

The administrator has been notified. Sorry for the inconvenience!

-

Back

+

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

+

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

+

{{ _('Back') }}

{% endblock %} diff --git a/app/templates/_post.html b/app/templates/_post.html index b5b60220e..7e05990e3 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -6,10 +6,13 @@ - - {{ post.author.username }} - - said {{ moment(post.timestamp).fromNow() }}: + {% set user_link %} + + {{ post.author.username }} + + {% endset %} + {{ _('%(username)s said %(when)s', + username=user_link, when=moment(post.timestamp).fromNow()) }}
{{ post.body }} diff --git a/app/templates/base.html b/app/templates/base.html index bf7523e93..d27b49be4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,7 +6,7 @@ {% if title %} {{ title }} - Microblog {% else %} - Welcome to Microblog + {{ _('Welcome to Microblog') }} {% endif %} @@ -63,5 +63,6 @@ crossorigin="anonymous"> {{ moment.include_moment() }} + {{ moment.lang(g.locale) }} diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html index ef7ca7532..f067d8f3e 100644 --- a/app/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -2,6 +2,6 @@ {% import "bootstrap_wtf.html" as wtf %} {% block content %} -

Edit Profile

+

{{ _('Edit Profile') }}

{{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index b5b560eed..3dec86c35 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,7 +2,7 @@ {% import "bootstrap_wtf.html" as wtf %} {% block content %} -

Hi, {{ current_user.username }}!

+

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

{% if form %} {{ wtf.quick_form(form) }} {% endif %} @@ -13,12 +13,12 @@

Hi, {{ current_user.username }}!

diff --git a/app/templates/login.html b/app/templates/login.html index 63439b553..bd16ffdcf 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -2,11 +2,11 @@ {% import "bootstrap_wtf.html" as wtf %} {% block content %} -

Sign In

+

{{ _('Sign In') }}

{{ wtf.quick_form(form) }} -

New User? Click to Register!

+

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

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

{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html index 26db86475..d93124d3e 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -2,6 +2,6 @@ {% import "bootstrap_wtf.html" as wtf %} {% block content %} -

Register

+

{{ _('Register') }}

{{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/reset_password.html b/app/templates/reset_password.html index bd5ccc245..15123f9c7 100644 --- a/app/templates/reset_password.html +++ b/app/templates/reset_password.html @@ -2,6 +2,6 @@ {% import "bootstrap_wtf.html" as wtf %} {% block content %} -

Reset Your Password

+

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

{{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/reset_password_request.html b/app/templates/reset_password_request.html index d60b1b531..a0f066d99 100644 --- a/app/templates/reset_password_request.html +++ b/app/templates/reset_password_request.html @@ -2,6 +2,6 @@ {% import "bootstrap_wtf.html" as wtf %} {% block content %} -

Reset Password

+

{{ _('Reset Password') }}

{{ wtf.quick_form(form) }} {% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html index 78120610d..44a239f22 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -5,26 +5,26 @@ -

User: {{ user.username }}

+

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

{% if user.about_me %}

{{ user.about_me }}

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

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

+

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

{% endif %} -

{{ user.followers_count() }} followers, {{ user.following_count() }} following.

+

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

{% if user == current_user %} -

Edit your profile

+

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

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

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

{% else %}

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

{% endif %} @@ -38,12 +38,12 @@

User: {{ user.username }}

diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po new file mode 100644 index 000000000..2e7a00f97 --- /dev/null +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -0,0 +1,259 @@ +# 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-10-03 15:49-0700\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:20 +msgid "Please log in to access this page." +msgstr "Por favor ingrese para acceder a esta página." + +#: app/email.py:21 +msgid "[Microblog] Reset Your Password" +msgstr "[Microblog] Nueva Contraseña" + +#: app/forms.py:12 app/forms.py:19 app/forms.py:50 +msgid "Username" +msgstr "Nombre de usuario" + +#: app/forms.py:13 app/forms.py:21 app/forms.py:43 +msgid "Password" +msgstr "Contraseña" + +#: app/forms.py:14 +msgid "Remember Me" +msgstr "Recordarme" + +#: app/forms.py:15 app/templates/login.html:5 +msgid "Sign In" +msgstr "Ingresar" + +#: app/forms.py:20 app/forms.py:38 +msgid "Email" +msgstr "Email" + +#: app/forms.py:23 app/forms.py:45 +msgid "Repeat Password" +msgstr "Repetir Contraseña" + +#: app/forms.py:24 app/templates/register.html:5 +msgid "Register" +msgstr "Registrarse" + +#: app/forms.py:29 app/forms.py:62 +msgid "Please use a different username." +msgstr "Por favor use un nombre de usuario diferente." + +#: app/forms.py:34 +msgid "Please use a different email address." +msgstr "Por favor use una dirección de email diferente." + +#: app/forms.py:39 app/forms.py:46 +msgid "Request Password Reset" +msgstr "Pedir una nueva contraseña" + +#: app/forms.py:51 +msgid "About me" +msgstr "Acerca de mí" + +#: app/forms.py:52 app/forms.py:67 +msgid "Submit" +msgstr "Enviar" + +#: app/forms.py:66 +msgid "Say something" +msgstr "Dí algo" + +#: app/forms.py:71 +msgid "Search" +msgstr "Buscar" + +#: app/routes.py:30 +msgid "Your post is now live!" +msgstr "¡Tu artículo ha sido publicado!" + +#: app/routes.py:66 +msgid "Invalid username or password" +msgstr "Nombre de usuario o contraseña inválidos" + +#: app/routes.py:92 +msgid "Congratulations, you are now a registered user!" +msgstr "¡Felicitaciones, ya eres un usuario registrado!" + +#: app/routes.py:107 +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/routes.py:124 +msgid "Your password has been reset." +msgstr "Tu contraseña ha sido cambiada." + +#: app/routes.py:152 +msgid "Your changes have been saved." +msgstr "Tus cambios han sido salvados." + +#: app/routes.py:157 app/templates/edit_profile.html:5 +msgid "Edit Profile" +msgstr "Editar Perfil" + +#: app/routes.py:166 app/routes.py:182 +#, python-format +msgid "User %(username)s not found." +msgstr "El usuario %(username)s no ha sido encontrado." + +#: app/routes.py:169 +msgid "You cannot follow yourself!" +msgstr "¡No te puedes seguir a tí mismo!" + +#: app/routes.py:173 +#, python-format +msgid "You are following %(username)s!" +msgstr "¡Ahora estás siguiendo a %(username)s!" + +#: app/routes.py:185 +msgid "You cannot unfollow yourself!" +msgstr "¡No te puedes dejar de seguir a tí mismo!" + +#: app/routes.py:189 +#, python-format +msgid "You are not following %(username)s." +msgstr "No estás siguiendo a %(username)s." + +#: app/templates/404.html:4 +msgid "Not Found" +msgstr "Página No Encontrada" + +#: app/templates/404.html:5 app/templates/500.html:6 +msgid "Back" +msgstr "Atrás" + +#: app/templates/500.html:4 +msgid "An unexpected error has occurred" +msgstr "Ha ocurrido un error inesperado" + +#: app/templates/500.html:5 +msgid "The administrator has been notified. Sorry for the inconvenience!" +msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" + +#: app/templates/_post.html:9 +#, python-format +msgid "%(username)s said %(when)s" +msgstr "%(username)s dijo %(when)s" + +#: 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:35 +msgid "Profile" +msgstr "Perfil" + +#: app/templates/base.html:36 +msgid "Logout" +msgstr "Salir" + +#: app/templates/index.html:5 +#, python-format +msgid "Hi, %(username)s!" +msgstr "¡Hola, %(username)s!" + +#: app/templates/index.html:17 app/templates/user.html:31 +msgid "Newer posts" +msgstr "Artículos siguientes" + +#: app/templates/index.html:22 app/templates/user.html:36 +msgid "Older posts" +msgstr "Artículos previos" + +#: app/templates/login.html:12 +msgid "New User?" +msgstr "¿Usuario Nuevo?" + +#: app/templates/login.html:12 +msgid "Click to Register!" +msgstr "¡Haz click aquí para registrarte!" + +#: app/templates/login.html:14 +msgid "Forgot Your Password?" +msgstr "¿Te olvidaste tu contraseña?" + +#: app/templates/login.html:15 +msgid "Click to Reset It" +msgstr "Haz click aquí para pedir una nueva" + +#: app/templates/reset_password.html:5 +msgid "Reset Your Password" +msgstr "Nueva Contraseña" + +#: app/templates/reset_password_request.html:5 +msgid "Reset Password" +msgstr "Nueva Contraseña" + +#: app/templates/search.html:4 +msgid "Search Results" +msgstr "Resultados de Búsqueda" + +#: app/templates/search.html:12 +msgid "Previous results" +msgstr "Resultados previos" + +#: app/templates/search.html:17 +msgid "Next results" +msgstr "Resultados próximos" + +#: app/templates/user.html:8 +msgid "User" +msgstr "Usuario" + +#: app/templates/user.html:11 +msgid "Last seen on" +msgstr "Última visita" + +#: app/templates/user.html:13 +#, python-format +msgid "%(count)d followers" +msgstr "%(count)d seguidores" + +#: app/templates/user.html:13 +#, 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 +msgid "Follow" +msgstr "Seguir" + +#: app/templates/user.html:19 +msgid "Unfollow" +msgstr "Dejar de seguir" + 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/config.py b/config.py index c330da370..b43f74f88 100644 --- a/config.py +++ b/config.py @@ -12,4 +12,5 @@ class Config: MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') ADMINS = ['your-email@example.com'] + LANGUAGES = ['en', 'es'] POSTS_PER_PAGE = 25 diff --git a/microblog.py b/microblog.py index 34798e5ba..1b4e20fd8 100644 --- a/microblog.py +++ b/microblog.py @@ -1,6 +1,6 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from app import app, db +from app import app, db, cli from app.models import User, Post From 5c2e76e8d8dbbbdc07bf131d88826e518fbfb6c0 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 5 Oct 2017 15:34:15 -0700 Subject: [PATCH 13/21] Chapter 14: Ajax (v0.14) --- app/models.py | 1 + app/routes.py | 18 +++++++- app/static/loading.gif | Bin 0 -> 673 bytes app/templates/_post.html | 12 ++++- app/templates/base.html | 17 +++++++ app/translate.py | 21 +++++++++ app/translations/es/LC_MESSAGES/messages.po | 42 ++++++++++++------ config.py | 1 + .../2b017edaa91f_add_language_to_posts.py | 32 +++++++++++++ 9 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 app/static/loading.gif create mode 100644 app/translate.py create mode 100644 migrations/versions/2b017edaa91f_add_language_to_posts.py diff --git a/app/models.py b/app/models.py index 5bf39b9cc..1e6426e1d 100644 --- a/app/models.py +++ b/app/models.py @@ -119,6 +119,7 @@ class Post(db.Model): 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') diff --git a/app/routes.py b/app/routes.py index fe87a6387..0888bb02a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,11 +4,13 @@ from flask_login import login_user, logout_user, current_user, login_required from flask_babel import _, get_locale import sqlalchemy as sa +from langdetect import detect, LangDetectException from app import app, db from app.forms import LoginForm, RegistrationForm, EditProfileForm, \ EmptyForm, PostForm, ResetPasswordRequestForm, ResetPasswordForm from app.models import User, Post from app.email import send_password_reset_email +from app.translate import translate @app.before_request @@ -25,7 +27,12 @@ def before_request(): def index(): form = PostForm() if form.validate_on_submit(): - post = Post(body=form.post.data, author=current_user) + 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!')) @@ -206,3 +213,12 @@ def unfollow(username): return redirect(url_for('user', username=username)) else: return redirect(url_for('index')) + + +@app.route('/translate', methods=['POST']) +@login_required +def translate_text(): + data = request.get_json() + return {'text': translate(data['text'], + data['source_language'], + data['dest_language'])} diff --git a/app/static/loading.gif b/app/static/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..d0bce1542342e912da81a2c260562df172f30d73 GIT binary patch literal 673 zcmZ?wbhEHb6krfw_{6~Q|Nnmm28Kh24mmkF0U1e2Nli^nlO|14{Lk&@8WQa67~pE8 zXTZz|lvDgC+Z`3#dv5h=E26FfcG1 zbL_hF&)}42ws10s6^G;;cE1^EoUR)U5A70}d2pLv!jVIT7j&Z~EblI3x0K*v_sV|m z0kj3v921Z^em#l`(k(o@H$3ZdDRc@9NidXDNbqrumReCGv$gd8+e8WW28HVqkJ_9i zH>s*<31KtHjANIPvi2#*6BEu%3Dak5O_t&NBI)H?V$TxT}#l{vOTn5naXTfF^&~Hhq+NX@#Ccc>y7T?;vjI&jdhsDsPJyAw*m0Qz>i}K7# zL9w50Ng{fT}A5JUe8lRK1h7_Y2;BWJDd=c6f&i?Wv5(5q?6|P zQw{>maxZP<537OA37Uk}7@%_$4o$EWe_Zl>&#id|lE-BpDC#+Fn|msJ%_2h{Hg1vP z#N8WAzfWasG}yq|xqE)DrWaOofX=z|?*pgc%{ig5vl!pqDlC|q&~Z0$&Rvsft&VO- z4MZj+%-+Vx%W}v;V76hyp=;+R;x+~t^Q%*xuFTQAF2})fSfTHDAs>sO!OBw`)&)o$ c0!CNZt))x~rAZP^^P&YOFfdqy5)K#u0POD40{{R3 literal 0 HcmV?d00001 diff --git a/app/templates/_post.html b/app/templates/_post.html index 7e05990e3..c1e33c02e 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -14,7 +14,17 @@ {{ _('%(username)s said %(when)s', username=user_link, when=moment(post.timestamp).fromNow()) }}
- {{ post.body }} + {{ post.body }} + {% if post.language and post.language != g.locale %} +

+ + {{ _('Translate') }} + + {% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index d27b49be4..eb1a0f1d9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -64,5 +64,22 @@ {{ moment.include_moment() }} {{ moment.lang(g.locale) }} + diff --git a/app/translate.py b/app/translate.py new file mode 100644 index 000000000..c82aa3505 --- /dev/null +++ b/app/translate.py @@ -0,0 +1,21 @@ +import requests +from flask_babel import _ +from app import app + + +def translate(text, source_language, dest_language): + if 'MS_TRANSLATOR_KEY' not in app.config or \ + not app.config['MS_TRANSLATOR_KEY']: + return _('Error: the translation service is not configured.') + auth = { + 'Ocp-Apim-Subscription-Key': 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 index 2e7a00f97..d46817570 100644 --- a/app/translations/es/LC_MESSAGES/messages.po +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2017-10-03 15:49-0700\n" +"POT-Creation-Date: 2017-10-05 15:32-0700\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -82,57 +82,65 @@ msgstr "Dí algo" msgid "Search" msgstr "Buscar" -#: app/routes.py:30 +#: app/routes.py:37 msgid "Your post is now live!" msgstr "¡Tu artículo ha sido publicado!" -#: app/routes.py:66 +#: app/routes.py:73 msgid "Invalid username or password" msgstr "Nombre de usuario o contraseña inválidos" -#: app/routes.py:92 +#: app/routes.py:99 msgid "Congratulations, you are now a registered user!" msgstr "¡Felicitaciones, ya eres un usuario registrado!" -#: app/routes.py:107 +#: app/routes.py:114 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/routes.py:124 +#: app/routes.py:131 msgid "Your password has been reset." msgstr "Tu contraseña ha sido cambiada." -#: app/routes.py:152 +#: app/routes.py:159 msgid "Your changes have been saved." msgstr "Tus cambios han sido salvados." -#: app/routes.py:157 app/templates/edit_profile.html:5 +#: app/routes.py:164 app/templates/edit_profile.html:5 msgid "Edit Profile" msgstr "Editar Perfil" -#: app/routes.py:166 app/routes.py:182 +#: app/routes.py:173 app/routes.py:189 #, python-format msgid "User %(username)s not found." msgstr "El usuario %(username)s no ha sido encontrado." -#: app/routes.py:169 +#: app/routes.py:176 msgid "You cannot follow yourself!" msgstr "¡No te puedes seguir a tí mismo!" -#: app/routes.py:173 +#: app/routes.py:180 #, python-format msgid "You are following %(username)s!" msgstr "¡Ahora estás siguiendo a %(username)s!" -#: app/routes.py:185 +#: app/routes.py:192 msgid "You cannot unfollow yourself!" msgstr "¡No te puedes dejar de seguir a tí mismo!" -#: app/routes.py:189 +#: app/routes.py:196 #, python-format msgid "You are not following %(username)s." msgstr "No estás siguiendo a %(username)s." +#: app/translate.py:10 +msgid "Error: the translation service is not configured." +msgstr "Error: el servicio de traducciones no está configurado." + +#: app/translate.py:17 +msgid "Error: the translation service failed." +msgstr "Error el servicio de traducciones ha fallado." + #: app/templates/404.html:4 msgid "Not Found" msgstr "Página No Encontrada" @@ -154,6 +162,10 @@ msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" msgid "%(username)s said %(when)s" msgstr "%(username)s dijo %(when)s" +#: app/templates/_post.html:19 +msgid "Translate" +msgstr "Traducir" + #: app/templates/base.html:4 msgid "Welcome to Microblog" msgstr "Bienvenido a Microblog" @@ -178,6 +190,10 @@ msgstr "Perfil" msgid "Logout" msgstr "Salir" +#: app/templates/base.html:73 +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!" diff --git a/config.py b/config.py index b43f74f88..31ccd05e7 100644 --- a/config.py +++ b/config.py @@ -13,4 +13,5 @@ class Config: MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') ADMINS = ['your-email@example.com'] LANGUAGES = ['en', 'es'] + MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') POSTS_PER_PAGE = 25 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 ### From f4cff4e264e5d3dbff4fce9d02bb902791a8b4cc Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Mon, 9 Oct 2017 00:09:22 -0700 Subject: [PATCH 14/21] Chapter 15: A Better Application Structure (v0.15) --- app/__init__.py | 110 +++++---- app/auth/__init__.py | 5 + app/auth/email.py | 14 ++ app/{ => auth}/forms.py | 34 +-- app/auth/routes.py | 85 +++++++ app/cli.py | 6 +- app/email.py | 19 +- app/errors.py | 13 - app/errors/__init__.py | 5 + app/errors/handlers.py | 14 ++ app/main/__init__.py | 5 + app/main/forms.py | 35 +++ app/main/routes.py | 152 ++++++++++++ app/models.py | 7 +- app/routes.py | 224 ------------------ app/templates/_post.html | 4 +- app/templates/{ => auth}/login.html | 4 +- app/templates/{ => auth}/register.html | 0 app/templates/{ => auth}/reset_password.html | 0 .../{ => auth}/reset_password_request.html | 0 app/templates/base.html | 12 +- app/templates/email/reset_password.html | 4 +- app/templates/email/reset_password.txt | 2 +- app/templates/{ => errors}/404.html | 2 +- app/templates/{ => errors}/500.html | 2 +- app/templates/user.html | 6 +- app/translate.py | 8 +- app/translations/es/LC_MESSAGES/messages.po | 203 ++++++++-------- config.py | 3 + microblog.py | 4 +- requirements.txt | 38 +++ tests.py | 15 +- 32 files changed, 566 insertions(+), 469 deletions(-) create mode 100644 app/auth/__init__.py create mode 100644 app/auth/email.py rename app/{ => auth}/forms.py (65%) create mode 100644 app/auth/routes.py delete mode 100644 app/errors.py create mode 100644 app/errors/__init__.py create mode 100644 app/errors/handlers.py create mode 100644 app/main/__init__.py create mode 100644 app/main/forms.py create mode 100644 app/main/routes.py delete mode 100644 app/routes.py rename app/templates/{ => auth}/login.html (52%) rename app/templates/{ => auth}/register.html (100%) rename app/templates/{ => auth}/reset_password.html (100%) rename app/templates/{ => auth}/reset_password_request.html (100%) rename app/templates/{ => errors}/404.html (58%) rename app/templates/{ => errors}/500.html (75%) create mode 100644 requirements.txt diff --git a/app/__init__.py b/app/__init__.py index 3e278236d..a94f30175 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,7 @@ import logging from logging.handlers import SMTPHandler, RotatingFileHandler import os -from flask import Flask, request +from flask import Flask, request, current_app from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager @@ -12,47 +12,73 @@ def get_locale(): - return request.accept_languages.best_match(app.config['LANGUAGES']) + return request.accept_languages.best_match(current_app.config['LANGUAGES']) -app = Flask(__name__) -app.config.from_object(Config) -db = SQLAlchemy(app) -migrate = Migrate(app, db) -login = LoginManager(app) -login.login_view = 'login' +db = SQLAlchemy() +migrate = Migrate() +login = LoginManager() +login.login_view = 'auth.login' login.login_message = _l('Please log in to access this page.') -mail = Mail(app) -moment = Moment(app) -babel = Babel(app, locale_selector=get_locale) - -if not app.debug: - 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 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') - - -from app import routes, models, errors +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) + + 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 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/forms.py b/app/auth/forms.py similarity index 65% rename from app/forms.py rename to app/auth/forms.py index 1fc5fa548..d8a4bad17 100644 --- a/app/forms.py +++ b/app/auth/forms.py @@ -1,9 +1,7 @@ from flask_wtf import FlaskForm from flask_babel import _, lazy_gettext as _l -from wtforms import StringField, PasswordField, BooleanField, SubmitField, \ - TextAreaField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, \ - Length +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 @@ -49,31 +47,3 @@ class ResetPasswordForm(FlaskForm): _l('Repeat Password'), validators=[DataRequired(), EqualTo('password')]) submit = SubmitField(_l('Request Password Reset')) - - -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')) 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 index 8c6697ad0..25e2a2750 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1,9 +1,11 @@ import os +from flask import Blueprint import click -from app import app +bp = Blueprint('cli', __name__, cli_group=None) -@app.cli.group() + +@bp.cli.group() def translate(): """Translation and localization commands.""" pass diff --git a/app/email.py b/app/email.py index 1f4d46f75..ee23da84c 100644 --- a/app/email.py +++ b/app/email.py @@ -1,8 +1,7 @@ from threading import Thread -from flask import render_template +from flask import current_app from flask_mail import Message -from flask_babel import _ -from app import app, mail +from app import mail def send_async_email(app, msg): @@ -14,15 +13,5 @@ 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=(app, msg)).start() - - -def send_password_reset_email(user): - token = user.get_reset_password_token() - send_email(_('[Microblog] Reset Your Password'), - sender=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)) + Thread(target=send_async_email, + args=(current_app._get_current_object(), msg)).start() diff --git a/app/errors.py b/app/errors.py deleted file mode 100644 index ed214c473..000000000 --- a/app/errors.py +++ /dev/null @@ -1,13 +0,0 @@ -from flask import render_template -from app import app, db - - -@app.errorhandler(404) -def not_found_error(error): - return render_template('404.html'), 404 - - -@app.errorhandler(500) -def internal_error(error): - db.session.rollback() - return render_template('500.html'), 500 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..cdd8f7bbb --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,35 @@ +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')) diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 000000000..2a9fc2e44 --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,152 @@ +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 +from app.models import User, Post +from app.translate import translate +from app.main import bp + + +@bp.before_app_request +def before_request(): + if current_user.is_authenticated: + current_user.last_seen = datetime.now(timezone.utc) + db.session.commit() + 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('/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'])} diff --git a/app/models.py b/app/models.py index 1e6426e1d..2108aa9f8 100644 --- a/app/models.py +++ b/app/models.py @@ -4,10 +4,11 @@ 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 app, db, login +from app import db, login followers = sa.Table( @@ -95,12 +96,12 @@ def following_posts(self): def get_reset_password_token(self, expires_in=600): return jwt.encode( {'reset_password': self.id, 'exp': time() + expires_in}, - app.config['SECRET_KEY'], algorithm='HS256') + current_app.config['SECRET_KEY'], algorithm='HS256') @staticmethod def verify_reset_password_token(token): try: - id = jwt.decode(token, app.config['SECRET_KEY'], + id = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])['reset_password'] except Exception: return diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index 0888bb02a..000000000 --- a/app/routes.py +++ /dev/null @@ -1,224 +0,0 @@ -from datetime import datetime, timezone -from urllib.parse import urlsplit -from flask import render_template, flash, redirect, url_for, request, g -from flask_login import login_user, logout_user, current_user, login_required -from flask_babel import _, get_locale -import sqlalchemy as sa -from langdetect import detect, LangDetectException -from app import app, db -from app.forms import LoginForm, RegistrationForm, EditProfileForm, \ - EmptyForm, PostForm, ResetPasswordRequestForm, ResetPasswordForm -from app.models import User, Post -from app.email import send_password_reset_email -from app.translate import translate - - -@app.before_request -def before_request(): - if current_user.is_authenticated: - current_user.last_seen = datetime.now(timezone.utc) - db.session.commit() - g.locale = str(get_locale()) - - -@app.route('/', methods=['GET', 'POST']) -@app.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('index')) - page = request.args.get('page', 1, type=int) - posts = db.paginate(current_user.following_posts(), page=page, - per_page=app.config['POSTS_PER_PAGE'], error_out=False) - next_url = url_for('index', page=posts.next_num) \ - if posts.has_next else None - prev_url = url_for('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) - - -@app.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=app.config['POSTS_PER_PAGE'], error_out=False) - next_url = url_for('explore', page=posts.next_num) \ - if posts.has_next else None - prev_url = url_for('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) - - -@app.route('/login', methods=['GET', 'POST']) -def login(): - if current_user.is_authenticated: - return redirect(url_for('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('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('index') - return redirect(next_page) - return render_template('login.html', title=_('Sign In'), form=form) - - -@app.route('/logout') -def logout(): - logout_user() - return redirect(url_for('index')) - - -@app.route('/register', methods=['GET', 'POST']) -def register(): - if current_user.is_authenticated: - return redirect(url_for('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('login')) - return render_template('register.html', title=_('Register'), form=form) - - -@app.route('/reset_password_request', methods=['GET', 'POST']) -def reset_password_request(): - if current_user.is_authenticated: - return redirect(url_for('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('login')) - return render_template('reset_password_request.html', - title=_('Reset Password'), form=form) - - -@app.route('/reset_password/', methods=['GET', 'POST']) -def reset_password(token): - if current_user.is_authenticated: - return redirect(url_for('index')) - user = User.verify_reset_password_token(token) - if not user: - return redirect(url_for('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('login')) - return render_template('reset_password.html', form=form) - - -@app.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=app.config['POSTS_PER_PAGE'], - error_out=False) - next_url = url_for('user', username=user.username, page=posts.next_num) \ - if posts.has_next else None - prev_url = url_for('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) - - -@app.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('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) - - -@app.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('index')) - if user == current_user: - flash(_('You cannot follow yourself!')) - return redirect(url_for('user', username=username)) - current_user.follow(user) - db.session.commit() - flash(_('You are following %(username)s!', username=username)) - return redirect(url_for('user', username=username)) - else: - return redirect(url_for('index')) - - -@app.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('index')) - if user == current_user: - flash(_('You cannot unfollow yourself!')) - return redirect(url_for('user', username=username)) - current_user.unfollow(user) - db.session.commit() - flash(_('You are not following %(username)s.', username=username)) - return redirect(url_for('user', username=username)) - else: - return redirect(url_for('index')) - - -@app.route('/translate', methods=['POST']) -@login_required -def translate_text(): - data = request.get_json() - return {'text': translate(data['text'], - data['source_language'], - data['dest_language'])} diff --git a/app/templates/_post.html b/app/templates/_post.html index c1e33c02e..8c0a9cd95 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -1,13 +1,13 @@
- + {% set user_link %} - + {{ post.author.username }} {% endset %} diff --git a/app/templates/login.html b/app/templates/auth/login.html similarity index 52% rename from app/templates/login.html rename to app/templates/auth/login.html index bd16ffdcf..4fe56690e 100644 --- a/app/templates/login.html +++ b/app/templates/auth/login.html @@ -4,9 +4,9 @@ {% block content %}

{{ _('Sign In') }}

{{ wtf.quick_form(form) }} -

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

+

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

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

{% endblock %} diff --git a/app/templates/register.html b/app/templates/auth/register.html similarity index 100% rename from app/templates/register.html rename to app/templates/auth/register.html diff --git a/app/templates/reset_password.html b/app/templates/auth/reset_password.html similarity index 100% rename from app/templates/reset_password.html rename to app/templates/auth/reset_password.html diff --git a/app/templates/reset_password_request.html b/app/templates/auth/reset_password_request.html similarity index 100% rename from app/templates/reset_password_request.html rename to app/templates/auth/reset_password_request.html diff --git a/app/templates/base.html b/app/templates/base.html index eb1a0f1d9..9e2374bc3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -17,30 +17,30 @@
{% set user_link %} - + {{ post.author.username }} {% endset %} diff --git a/app/templates/base.html b/app/templates/base.html index 63d0f197c..b64e160d4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -87,6 +87,36 @@ const data = await response.json(); document.getElementById(destElem).innerText = data.text; } + + function initialize_popovers() { + const popups = document.getElementsByClassName('user_popup'); + for (let i = 0; i < popups.length; i++) { + const popover = new bootstrap.Popover(popups[i], { + content: 'Loading...', + trigger: 'hover focus', + placement: 'right', + html: true, + sanitize: false, + delay: {show: 500, hide: 0}, + container: popups[i], + customClass: 'd-inline', + }); + popups[i].addEventListener('show.bs.popover', async (ev) => { + if (ev.target.popupLoaded) { + return; + } + const response = await fetch('/user/' + ev.target.innerText.trim() + '/popup'); + const data = await response.text(); + const popover = bootstrap.Popover.getInstance(ev.target); + if (popover && data) { + ev.target.popupLoaded = true; + popover.setContent({'.popover-body': data}); + flask_moment_render_all(); + } + }); + } + } + document.addEventListener('DOMContentLoaded', initialize_popovers); 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 %} +
From ef7f72b7092c3457342c6cedb6a40e7eb510dbae Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 12 Nov 2017 23:53:18 -0800 Subject: [PATCH 20/21] Chapter 21: User Notifications (v0.21) --- app/main/forms.py | 6 ++ app/main/routes.py | 57 ++++++++++++- app/models.py | 57 +++++++++++++ app/templates/base.html | 32 ++++++++ app/templates/messages.html | 22 +++++ app/templates/send_message.html | 7 ++ app/templates/user.html | 3 + app/translations/es/LC_MESSAGES/messages.po | 81 +++++++++++++------ microblog.py | 5 +- .../versions/d049de007ccf_private_messages.py | 53 ++++++++++++ .../versions/f7ac3d27bb1d_notifications.py | 46 +++++++++++ 11 files changed, 341 insertions(+), 28 deletions(-) create mode 100644 app/templates/messages.html create mode 100644 app/templates/send_message.html create mode 100644 migrations/versions/d049de007ccf_private_messages.py create mode 100644 migrations/versions/f7ac3d27bb1d_notifications.py diff --git a/app/main/forms.py b/app/main/forms.py index b28d8f0c7..135beae1c 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -45,3 +45,9 @@ def __init__(self, *args, **kwargs): if 'meta' not in kwargs: kwargs['meta'] = {'csrf': False} super(SearchForm, self).__init__(*args, **kwargs) + + +class MessageForm(FlaskForm): + message = TextAreaField(_l('Message'), validators=[ + DataRequired(), Length(min=1, max=140)]) + submit = SubmitField(_l('Submit')) diff --git a/app/main/routes.py b/app/main/routes.py index 555b14d96..c06f42f13 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -6,8 +6,9 @@ import sqlalchemy as sa from langdetect import detect, LangDetectException from app import db -from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm -from app.models import User, Post +from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm, \ + MessageForm +from app.models import User, Post, Message, Notification from app.translate import translate from app.main import bp @@ -175,3 +176,55 @@ def search(): if page > 1 else None return render_template('search.html', title=_('Search'), posts=posts, next_url=next_url, prev_url=prev_url) + + +@bp.route('/send_message/', methods=['GET', 'POST']) +@login_required +def send_message(recipient): + user = 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] diff --git a/app/models.py b/app/models.py index 752392adc..0d10d7ca1 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone from hashlib import md5 +import json from time import time from typing import Optional import sqlalchemy as sa @@ -76,6 +77,7 @@ class User(UserMixin, db.Model): 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') @@ -87,6 +89,12 @@ class User(UserMixin, db.Model): 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) @@ -152,6 +160,20 @@ def verify_reset_password_token(token): 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): @@ -172,3 +194,38 @@ class Post(SearchableMixin, db.Model): 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/templates/base.html b/app/templates/base.html index b64e160d4..aa50e1d84 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -43,6 +43,16 @@ {{ _('Login') }} {% else %} + @@ -117,6 +127,28 @@ } } document.addEventListener('DOMContentLoaded', initialize_popovers); + + function set_message_count(n) { + const count = document.getElementById('message_count'); + count.innerText = n; + count.style.visibility = n ? 'visible' : 'hidden'; + } + + {% if current_user.is_authenticated %} + function initialize_notifications() { + let since = 0; + setInterval(async function() { + const response = await fetch('{{ url_for('main.notifications') }}?since=' + since); + const notifications = await response.json(); + for (let i = 0; i < notifications.length; i++) { + if (notifications[i].name == 'unread_message_count') + set_message_count(notifications[i].data); + since = notifications[i].timestamp; + } + }, 10000); + } + document.addEventListener('DOMContentLoaded', initialize_notifications); + {% endif %} 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/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 index ba82ee48f..7060efe94 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -28,6 +28,9 @@

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

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

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

+ {% endif %}
diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po index df667c98a..dac426423 100644 --- a/app/translations/es/LC_MESSAGES/messages.po +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2017-11-25 18:23-0800\n" +"POT-Creation-Date: 2017-11-25 18:26-0800\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -94,7 +94,7 @@ msgstr "Tu contraseña ha sido cambiada." msgid "About me" msgstr "Acerca de mí" -#: app/main/forms.py:13 app/main/forms.py:28 +#: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44 msgid "Submit" msgstr "Enviar" @@ -106,47 +106,59 @@ msgstr "Dí algo" msgid "Search" msgstr "Buscar" +#: app/main/forms.py:43 +msgid "Message" +msgstr "Mensaje" + #: app/main/routes.py:36 msgid "Your post is now live!" msgstr "¡Tu artículo ha sido publicado!" -#: app/main/routes.py:87 +#: app/main/routes.py:94 msgid "Your changes have been saved." msgstr "Tus cambios han sido salvados." -#: app/main/routes.py:92 app/templates/edit_profile.html:5 +#: app/main/routes.py:99 app/templates/edit_profile.html:5 msgid "Edit Profile" msgstr "Editar Perfil" -#: app/main/routes.py:101 app/main/routes.py:117 +#: app/main/routes.py:108 app/main/routes.py:124 #, python-format msgid "User %(username)s not found." msgstr "El usuario %(username)s no ha sido encontrado." -#: app/main/routes.py:104 +#: app/main/routes.py:111 msgid "You cannot follow yourself!" msgstr "¡No te puedes seguir a tí mismo!" -#: app/main/routes.py:108 +#: app/main/routes.py:115 #, python-format msgid "You are following %(username)s!" msgstr "¡Ahora estás siguiendo a %(username)s!" -#: app/main/routes.py:120 +#: app/main/routes.py:127 msgid "You cannot unfollow yourself!" msgstr "¡No te puedes dejar de seguir a tí mismo!" -#: app/main/routes.py:124 +#: app/main/routes.py:131 #, python-format msgid "You are not following %(username)s." msgstr "No estás siguiendo a %(username)s." -#: app/templates/_post.html:14 +#: app/main/routes.py:170 +msgid "Your message has been sent." +msgstr "Tu mensaje ha sido enviado." + +#: app/main/routes.py:172 +msgid "Send Message" +msgstr "Enviar Mensaje" + +#: app/templates/_post.html:16 #, python-format msgid "%(username)s said %(when)s" msgstr "%(username)s dijo %(when)s" -#: app/templates/_post.html:25 +#: app/templates/_post.html:27 msgid "Translate" msgstr "Traducir" @@ -166,15 +178,19 @@ msgstr "Explorar" msgid "Login" msgstr "Ingresar" -#: app/templates/base.html:35 +#: app/templates/base.html:36 app/templates/messages.html:4 +msgid "Messages" +msgstr "Mensajes" + +#: app/templates/base.html:45 msgid "Profile" msgstr "Perfil" -#: app/templates/base.html:36 +#: app/templates/base.html:46 msgid "Logout" msgstr "Salir" -#: app/templates/base.html:73 +#: app/templates/base.html:83 msgid "Error: Could not contact server." msgstr "Error: el servidor no pudo ser contactado." @@ -183,40 +199,53 @@ msgstr "Error: el servidor no pudo ser contactado." msgid "Hi, %(username)s!" msgstr "¡Hola, %(username)s!" -#: app/templates/index.html:17 app/templates/user.html:31 +#: app/templates/index.html:17 app/templates/user.html:34 msgid "Newer posts" msgstr "Artículos siguientes" -#: app/templates/index.html:22 app/templates/user.html:36 +#: app/templates/index.html:22 app/templates/user.html:39 msgid "Older posts" msgstr "Artículos previos" +#: app/templates/messages.html:12 +msgid "Newer messages" +msgstr "Mensajes siguientes" + +#: app/templates/messages.html:17 +msgid "Older messages" +msgstr "Mensajes previos" + #: app/templates/search.html:4 msgid "Search Results" -msgstr "Resultados de Búsqueda" +msgstr "" #: app/templates/search.html:12 msgid "Previous results" -msgstr "Resultados previos" +msgstr "" #: app/templates/search.html:17 msgid "Next results" -msgstr "Resultados próximos" +msgstr "" + +#: app/templates/send_message.html:5 +#, python-format +msgid "Send Message to %(recipient)s" +msgstr "Enviar Mensaje a %(recipient)s" #: app/templates/user.html:8 msgid "User" msgstr "Usuario" -#: app/templates/user.html:11 +#: app/templates/user.html:11 app/templates/user_popup.html:9 msgid "Last seen on" msgstr "Última visita" -#: app/templates/user.html:13 +#: app/templates/user.html:13 app/templates/user_popup.html:11 #, python-format msgid "%(count)d followers" msgstr "%(count)d seguidores" -#: app/templates/user.html:13 +#: app/templates/user.html:13 app/templates/user_popup.html:11 #, python-format msgid "%(count)d following" msgstr "siguiendo a %(count)d" @@ -225,14 +254,18 @@ msgstr "siguiendo a %(count)d" msgid "Edit your profile" msgstr "Editar tu perfil" -#: app/templates/user.html:17 +#: app/templates/user.html:17 app/templates/user_popup.html:14 msgid "Follow" msgstr "Seguir" -#: app/templates/user.html:19 +#: app/templates/user.html:19 app/templates/user_popup.html:16 msgid "Unfollow" msgstr "Dejar de seguir" +#: app/templates/user.html:22 +msgid "Send private message" +msgstr "Enviar mensaje privado" + #: app/templates/auth/login.html:12 msgid "New User?" msgstr "¿Usuario Nuevo?" diff --git a/microblog.py b/microblog.py index ed57a27ba..5f4baa09b 100644 --- a/microblog.py +++ b/microblog.py @@ -1,11 +1,12 @@ import sqlalchemy as sa import sqlalchemy.orm as so from app import create_app, db -from app.models import User, Post +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} + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post, + 'Message': Message, 'Notification': Notification} diff --git a/migrations/versions/d049de007ccf_private_messages.py b/migrations/versions/d049de007ccf_private_messages.py new file mode 100644 index 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/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 ### From 3218f2204a37cd751f57096d3994a2878ba76c86 Mon Sep 17 00:00:00 2001 From: Hassairi Date: Sun, 25 Feb 2024 00:20:33 +0100 Subject: [PATCH 21/21] recommit --- app/main/routes.py | 5 +++++ app/templates/about.html | 13 +++++++++++++ app/templates/base.html | 10 ++++++++++ 3 files changed, 28 insertions(+) create mode 100644 app/templates/about.html diff --git a/app/main/routes.py b/app/main/routes.py index c06f42f13..8270338c9 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -11,6 +11,7 @@ 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 @@ -228,3 +229,7 @@ def notifications(): '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/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/base.html b/app/templates/base.html index aa50e1d84..55e2ff45b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -63,7 +63,17 @@
+ + {% block content %}{% endblock %} + + + + +
{% with messages = get_flashed_messages() %} {% if messages %}