Compare commits

..

20 Commits
main ... 2023

Author SHA1 Message Date
Miguel Grinberg 4ec1508757
Chapter 23: Application Programming Interfaces (APIs) (v0.23) 2023-07-27 17:39:36 +01:00
Miguel Grinberg b909b8e072
Chapter 22: Background Jobs (v0.22) 2023-07-27 17:39:33 +01:00
Miguel Grinberg 9d67b1ab7f
Chapter 21: User Notifications (v0.21) 2023-07-27 17:38:53 +01:00
Miguel Grinberg 93b3770418
Chapter 20: Some JavaScript Magic (v0.20) 2023-07-27 17:38:47 +01:00
Miguel Grinberg 53f4d49cb9
Chapter 19: Deployment on Docker Containers (v0.19) 2023-05-12 00:33:14 +01:00
Miguel Grinberg ae62fc9251
Chapter 18: Deployment on Heroku (v0.18) 2023-05-12 00:33:13 +01:00
Miguel Grinberg 7d16e19169
Chapter 17: Deployment on Linux (v0.17) 2023-05-12 00:33:13 +01:00
Miguel Grinberg 87ce9d2ca0
Chapter 16: Full-Text Search (v0.16) 2023-05-12 00:33:13 +01:00
Miguel Grinberg 3096f0042e
Chapter 15: A Better Application Structure (v0.15) 2023-05-12 00:33:13 +01:00
Miguel Grinberg 5040abda19
Chapter 14: Ajax (v0.14) 2023-05-12 00:33:13 +01:00
Miguel Grinberg 8fce57e248
Chapter 13: I18n and L10n (v0.13) 2023-05-12 00:33:08 +01:00
Miguel Grinberg a871e7b5dd
Chapter 12: Dates and Times (v0.12) 2023-05-01 20:20:22 +01:00
Miguel Grinberg b71c901c5a
Chapter 11: Facelift (v0.11) 2023-05-01 20:20:22 +01:00
Miguel Grinberg 40979c0166
Chapter 10: Email Support (v0.10) 2023-05-01 20:20:22 +01:00
Miguel Grinberg 62287d943a
Chapter 9: Pagination (v0.9) 2023-05-01 20:20:17 +01:00
Miguel Grinberg 4314a44fba
Chapter 8: Followers (v0.8) 2023-04-30 12:40:42 +01:00
Miguel Grinberg 9ab5c4a3b3
Chapter 7: Error Handling (v0.7) 2023-04-30 00:56:07 +01:00
Miguel Grinberg f898236240
Chapter 6: Profile Page and Avatars (v0.6) 2023-04-30 00:56:07 +01:00
Miguel Grinberg 7141d80e44
Chapter 5: User Logins (v0.5) 2023-04-30 00:56:06 +01:00
Miguel Grinberg e42dc0d326
Chapter 4: Database (v0.4) 2023-04-30 00:56:00 +01:00
40 changed files with 550 additions and 454 deletions

View File

@ -1 +1,2 @@
FLASK_APP=microblog.py FLASK_APP=microblog.py
FLASK_DEBUG=1

View File

@ -1,13 +1,8 @@
FROM python:slim FROM python:slim
RUN useradd microblog
WORKDIR /home/microblog
COPY requirements.txt requirements.txt COPY requirements.txt requirements.txt
RUN python -m venv venv RUN pip install -r requirements.txt
RUN venv/bin/pip install -r requirements.txt RUN pip install gunicorn pymysql cryptography
RUN venv/bin/pip install gunicorn pymysql cryptography
COPY app app COPY app app
COPY migrations migrations COPY migrations migrations
@ -15,9 +10,7 @@ COPY microblog.py config.py boot.sh ./
RUN chmod a+x boot.sh RUN chmod a+x boot.sh
ENV FLASK_APP microblog.py ENV FLASK_APP microblog.py
RUN flask translate compile
RUN chown -R microblog:microblog ./
USER microblog
EXPOSE 5000 EXPOSE 5000
ENTRYPOINT ["./boot.sh"] ENTRYPOINT ["./boot.sh"]

4
Vagrantfile vendored
View File

@ -1,6 +1,6 @@
Vagrant.configure("2") do |config| Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/focal64" config.vm.box = "ubuntu/jammy64"
config.vm.network "private_network", ip: "192.168.33.10" config.vm.network "private_network", ip: "192.168.56.10"
config.vm.provider "virtualbox" do |vb| config.vm.provider "virtualbox" do |vb|
vb.memory = "2048" vb.memory = "2048"
end end

View File

@ -6,7 +6,6 @@ from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from flask_mail import Mail from flask_mail import Mail
from flask_bootstrap import Bootstrap
from flask_moment import Moment from flask_moment import Moment
from flask_babel import Babel, lazy_gettext as _l from flask_babel import Babel, lazy_gettext as _l
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
@ -14,13 +13,17 @@ from redis import Redis
import rq import rq
from config import Config from config import Config
def get_locale():
return request.accept_languages.best_match(current_app.config['LANGUAGES'])
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login = LoginManager() login = LoginManager()
login.login_view = 'auth.login' login.login_view = 'auth.login'
login.login_message = _l('Please log in to access this page.') login.login_message = _l('Please log in to access this page.')
mail = Mail() mail = Mail()
bootstrap = Bootstrap()
moment = Moment() moment = Moment()
babel = Babel() babel = Babel()
@ -33,9 +36,8 @@ def create_app(config_class=Config):
migrate.init_app(app, db) migrate.init_app(app, db)
login.init_app(app) login.init_app(app)
mail.init_app(app) mail.init_app(app)
bootstrap.init_app(app)
moment.init_app(app) moment.init_app(app)
babel.init_app(app) babel.init_app(app, locale_selector=get_locale)
app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
if app.config['ELASTICSEARCH_URL'] else None if app.config['ELASTICSEARCH_URL'] else None
app.redis = Redis.from_url(app.config['REDIS_URL']) app.redis = Redis.from_url(app.config['REDIS_URL'])
@ -50,6 +52,9 @@ def create_app(config_class=Config):
from app.main import bp as main_bp from app.main import bp as main_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
from app.cli import bp as cli_bp
app.register_blueprint(cli_bp)
from app.api import bp as api_bp from app.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(api_bp, url_prefix='/api')
@ -91,9 +96,4 @@ def create_app(config_class=Config):
return app return app
@babel.localeselector
def get_locale():
return request.accept_languages.best_match(current_app.config['LANGUAGES'])
from app import models from app import models

View File

@ -1,9 +1,11 @@
import os import os
from flask import Blueprint
import click import click
bp = Blueprint('cli', __name__, cli_group=None)
def register(app):
@app.cli.group() @bp.cli.group()
def translate(): def translate():
"""Translation and localization commands.""" """Translation and localization commands."""
pass pass

View File

@ -13,7 +13,7 @@ class EditProfileForm(FlaskForm):
submit = SubmitField(_l('Submit')) submit = SubmitField(_l('Submit'))
def __init__(self, original_username, *args, **kwargs): def __init__(self, original_username, *args, **kwargs):
super(EditProfileForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.original_username = original_username self.original_username = original_username
def validate_username(self, username): def validate_username(self, username):

View File

@ -38,7 +38,7 @@ def index():
flash(_('Your post is now live!')) flash(_('Your post is now live!'))
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
posts = current_user.followed_posts().paginate( posts = current_user.following_posts().paginate(
page=page, per_page=current_app.config['POSTS_PER_PAGE'], page=page, per_page=current_app.config['POSTS_PER_PAGE'],
error_out=False) error_out=False)
next_url = url_for('main.index', page=posts.next_num) \ next_url = url_for('main.index', page=posts.next_num) \
@ -151,9 +151,10 @@ def unfollow(username):
@bp.route('/translate', methods=['POST']) @bp.route('/translate', methods=['POST'])
@login_required @login_required
def translate_text(): def translate_text():
return jsonify({'text': translate(request.form['text'], data = request.get_json()
request.form['source_language'], return {'text': translate(data['text'],
request.form['dest_language'])}) data['source_language'],
data['dest_language'])}
@bp.route('/search') @bp.route('/search')

View File

@ -24,7 +24,7 @@ class SearchableMixin(object):
for i in range(len(ids)): for i in range(len(ids)):
when.append((ids[i], i)) when.append((ids[i], i))
return cls.query.filter(cls.id.in_(ids)).order_by( return cls.query.filter(cls.id.in_(ids)).order_by(
db.case(when, value=cls.id)), total db.case(*when, value=cls.id)), total
@classmethod @classmethod
def before_commit(cls, session): def before_commit(cls, session):
@ -84,8 +84,10 @@ class PaginatedAPIMixin(object):
followers = db.Table( followers = db.Table(
'followers', 'followers',
db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), db.Column('follower_id', db.Integer, db.ForeignKey('user.id'),
db.Column('followed_id', db.Integer, db.ForeignKey('user.id')) primary_key=True),
db.Column('followed_id', db.Integer, db.ForeignKey('user.id'),
primary_key=True)
) )
@ -94,26 +96,32 @@ class User(UserMixin, PaginatedAPIMixin, db.Model):
username = db.Column(db.String(64), index=True, unique=True) username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True) email = db.Column(db.String(120), index=True, unique=True)
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
posts = db.relationship('Post', backref='author', lazy='dynamic') posts = db.relationship('Post', back_populates='author', lazy='dynamic')
about_me = db.Column(db.String(140)) about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime, default=datetime.utcnow) last_seen = db.Column(db.DateTime, default=datetime.utcnow)
token = db.Column(db.String(32), index=True, unique=True) token = db.Column(db.String(32), index=True, unique=True)
token_expiration = db.Column(db.DateTime) token_expiration = db.Column(db.DateTime)
followed = db.relationship( following = db.relationship(
'User', secondary=followers, 'User', secondary=followers,
primaryjoin=(followers.c.follower_id == id), primaryjoin=(followers.c.follower_id == id),
secondaryjoin=(followers.c.followed_id == id), secondaryjoin=(followers.c.followed_id == id),
backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') lazy='dynamic', back_populates='followers')
followers = db.relationship(
'User', secondary=followers,
primaryjoin=(followers.c.followed_id == id),
secondaryjoin=(followers.c.follower_id == id),
lazy='dynamic', back_populates='following')
messages_sent = db.relationship('Message', messages_sent = db.relationship('Message',
foreign_keys='Message.sender_id', foreign_keys='Message.sender_id',
backref='author', lazy='dynamic') lazy='dynamic', back_populates='author')
messages_received = db.relationship('Message', messages_received = db.relationship('Message',
foreign_keys='Message.recipient_id', foreign_keys='Message.recipient_id',
backref='recipient', lazy='dynamic') lazy='dynamic',
back_populates='recipient')
last_message_read_time = db.Column(db.DateTime) last_message_read_time = db.Column(db.DateTime)
notifications = db.relationship('Notification', backref='user', notifications = db.relationship('Notification', lazy='dynamic',
lazy='dynamic') back_populates='user')
tasks = db.relationship('Task', backref='user', lazy='dynamic') tasks = db.relationship('Task', lazy='dynamic', back_populates='user')
def __repr__(self): def __repr__(self):
return '<User {}>'.format(self.username) return '<User {}>'.format(self.username)
@ -126,27 +134,25 @@ class User(UserMixin, PaginatedAPIMixin, db.Model):
def avatar(self, size): def avatar(self, size):
digest = md5(self.email.lower().encode('utf-8')).hexdigest() digest = md5(self.email.lower().encode('utf-8')).hexdigest()
return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}'
digest, size)
def follow(self, user): def follow(self, user):
if not self.is_following(user): if not self.is_following(user):
self.followed.append(user) self.following.append(user)
def unfollow(self, user): def unfollow(self, user):
if self.is_following(user): if self.is_following(user):
self.followed.remove(user) self.following.remove(user)
def is_following(self, user): def is_following(self, user):
return self.followed.filter( return user in self.following
followers.c.followed_id == user.id).count() > 0
def followed_posts(self): def following_posts(self):
followed = Post.query.join( following = Post.query.join(
followers, (followers.c.followed_id == Post.user_id)).filter( followers, (followers.c.followed_id == Post.user_id)).filter(
followers.c.follower_id == self.id) followers.c.follower_id == self.id)
own = Post.query.filter_by(user_id=self.id) own = Post.query.filter_by(user_id=self.id)
return followed.union(own).order_by(Post.timestamp.desc()) return following.union(own).order_by(Post.timestamp.desc())
def get_reset_password_token(self, expires_in=600): def get_reset_password_token(self, expires_in=600):
return jwt.encode( return jwt.encode(
@ -245,8 +251,9 @@ class Post(SearchableMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.String(140)) body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
language = db.Column(db.String(5)) language = db.Column(db.String(5))
author = db.relationship('User', back_populates='posts')
def __repr__(self): def __repr__(self):
return '<Post {}>'.format(self.body) return '<Post {}>'.format(self.body)
@ -254,10 +261,14 @@ class Post(SearchableMixin, db.Model):
class Message(db.Model): class Message(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
body = db.Column(db.String(140)) body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
author = db.relationship('User', foreign_keys='Message.sender_id',
back_populates='messages_sent')
recipient = db.relationship('User', foreign_keys='Message.recipient_id',
back_populates='messages_received')
def __repr__(self): def __repr__(self):
return '<Message {}>'.format(self.body) return '<Message {}>'.format(self.body)
@ -266,9 +277,10 @@ class Message(db.Model):
class Notification(db.Model): class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), index=True) name = db.Column(db.String(128), index=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
timestamp = db.Column(db.Float, index=True, default=time) timestamp = db.Column(db.Float, index=True, default=time)
payload_json = db.Column(db.Text) payload_json = db.Column(db.Text)
user = db.relationship('User', back_populates='notifications')
def get_data(self): def get_data(self):
return json.loads(str(self.payload_json)) return json.loads(str(self.payload_json))
@ -280,6 +292,7 @@ class Task(db.Model):
description = db.Column(db.String(128)) description = db.Column(db.String(128))
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
complete = db.Column(db.Boolean, default=False) complete = db.Column(db.Boolean, default=False)
user = db.relationship('User', back_populates='tasks')
def get_rq_job(self): def get_rq_job(self):
try: try:

View File

@ -7,7 +7,7 @@ def add_to_index(index, model):
payload = {} payload = {}
for field in model.__searchable__: for field in model.__searchable__:
payload[field] = getattr(model, field) payload[field] = getattr(model, field)
current_app.elasticsearch.index(index=index, id=model.id, body=payload) current_app.elasticsearch.index(index=index, id=model.id, document=payload)
def remove_from_index(index, model): def remove_from_index(index, model):
@ -21,7 +21,8 @@ def query_index(index, query, page, per_page):
return [], 0 return [], 0
search = current_app.elasticsearch.search( search = current_app.elasticsearch.search(
index=index, index=index,
body={'query': {'multi_match': {'query': query, 'fields': ['*']}}, query={'multi_match': {'query': query, 'fields': ['*']}},
'from': (page - 1) * per_page, 'size': per_page}) from_=(page - 1) * per_page,
size=per_page)
ids = [int(hit['_id']) for hit in search['hits']['hits']] ids = [int(hit['_id']) for hit in search['hits']['hits']]
return ids, search['hits']['total']['value'] return ids, search['hits']['total']['value']

View File

@ -7,11 +7,9 @@
</td> </td>
<td> <td>
{% set user_link %} {% set user_link %}
<span class="user_popup"> <a class="user_popup" href="{{ url_for('main.user', username=post.author.username) }}">
<a href="{{ url_for('main.user', username=post.author.username) }}">
{{ post.author.username }} {{ post.author.username }}
</a> </a>
</span>
{% endset %} {% endset %}
{{ _('%(username)s said %(when)s', {{ _('%(username)s said %(when)s',
username=user_link, when=moment(post.timestamp).fromNow()) }} username=user_link, when=moment(post.timestamp).fromNow()) }}
@ -21,8 +19,8 @@
<br><br> <br><br>
<span id="translation{{ post.id }}"> <span id="translation{{ post.id }}">
<a href="javascript:translate( <a href="javascript:translate(
'#post{{ post.id }}', 'post{{ post.id }}',
'#translation{{ post.id }}', 'translation{{ post.id }}',
'{{ post.language }}', '{{ post.language }}',
'{{ g.locale }}');">{{ _('Translate') }}</a> '{{ g.locale }}');">{{ _('Translate') }}</a>
</span> </span>

View File

@ -1,14 +1,9 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'bootstrap/wtf.html' as wtf %} {% import "bootstrap_wtf.html" as wtf %}
{% block app_content %} {% block content %}
<h1>{{ _('Sign In') }}</h1> <h1>{{ _('Sign In') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div>
</div>
<br>
<p>{{ _('New User?') }} <a href="{{ url_for('auth.register') }}">{{ _('Click to Register!') }}</a></p> <p>{{ _('New User?') }} <a href="{{ url_for('auth.register') }}">{{ _('Click to Register!') }}</a></p>
<p> <p>
{{ _('Forgot Your Password?') }} {{ _('Forgot Your Password?') }}

View File

@ -1,11 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %} {% import "bootstrap_wtf.html" as wtf %}
{% block app_content %} {% block content %}
<h1>{{ _('Register') }}</h1> <h1>{{ _('Register') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,11 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %} {% import "bootstrap_wtf.html" as wtf %}
{% block app_content %} {% block content %}
<h1>{{ _('Reset Your Password') }}</h1> <h1>{{ _('Reset Your Password') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,11 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %} {% import "bootstrap_wtf.html" as wtf %}
{% block app_content %} {% block content %}
<h1>{{ _('Reset Password') }}</h1> <h1>{{ _('Reset Password') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,26 +1,30 @@
{% extends 'bootstrap/base.html' %} <!doctype html>
<html lang="en">
{% block title %} <head>
{% if title %}{{ title }} - Microblog{% else %}{{ _('Welcome to Microblog') }}{% endif %} <meta charset="utf-8">
{% endblock %} <meta name="viewport" content="width=device-width, initial-scale=1">
{% if title %}
{% block navbar %} <title>{{ title }} - Microblog</title>
<nav class="navbar navbar-default"> {% else %}
<title>{{ _('Welcome to Microblog') }}</title>
{% endif %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container"> <div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url_for('main.index') }}">Microblog</a> <a class="navbar-brand" href="{{ url_for('main.index') }}">Microblog</a>
</div> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1"> <span class="navbar-toggler-icon"></span>
<ul class="nav navbar-nav"> </button>
<li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
</ul> <li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('main.index') }}">{{ _('Home') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a>
</li>
{% if g.search_form %} {% if g.search_form %}
<form class="navbar-form navbar-left" method="get" action="{{ url_for('main.search') }}"> <form class="navbar-form navbar-left" method="get" action="{{ url_for('main.search') }}">
<div class="form-group"> <div class="form-group">
@ -28,31 +32,35 @@
</div> </div>
</form> </form>
{% endif %} {% endif %}
<ul class="nav navbar-nav navbar-right"> </ul>
<ul class="navbar-nav mb-2 mb-lg-0">
{% if current_user.is_anonymous %} {% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li> <li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('auth.login') }}">{{ _('Login') }}</a>
</li>
{% else %} {% else %}
<li> <li class="nav-item">
<a href="{{ url_for('main.messages') }}">{{ _('Messages') }} <a class="nav-link" aria-current="page" href="{{ url_for('main.messages') }}">{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %} {% set new_messages = current_user.new_messages() %}
<span id="message_count" class="badge" <span id="message_count" class="badge text-bg-danger"
style="visibility: {% if new_messages %}visible style="visibility: {% if new_messages %}visible
{% else %}hidden{% endif %};"> {% else %}hidden{% endif %};">
{{ new_messages }} {{ new_messages }}
</span> </span>
</a> </a>
</li> </li>
<li><a href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a></li> <li class="nav-item">
<li><a href="{{ url_for('auth.logout') }}">{{ _('Logout') }}</a></li> <a class="nav-link" aria-current="page" href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="{{ url_for('auth.logout') }}">{{ _('Logout') }}</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
{% endblock %} <div class="container mt-3">
{% block content %}
<div class="container">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% with tasks = current_user.get_tasks_in_progress() %} {% with tasks = current_user.get_tasks_in_progress() %}
{% if tasks %} {% if tasks %}
@ -65,6 +73,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
@ -72,85 +81,78 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %}
{# application content needs to be provided in the app_content block #}
{% block app_content %}{% endblock %}
</div> </div>
{% endblock %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" crossorigin="anonymous"></script>
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }} {{ moment.include_moment() }}
{{ moment.lang(g.locale) }} {{ moment.lang(g.locale) }}
<script> <script>
function translate(sourceElem, destElem, sourceLang, destLang) { async function translate(sourceElem, destElem, sourceLang, destLang) {
$(destElem).html('<img src="{{ url_for('static', filename='loading.gif') }}">'); document.getElementById(destElem).innerHTML =
$.post('/translate', { '<img src="{{ url_for('static', filename='loading.gif') }}">';
text: $(sourceElem).text(), const response = await fetch('/translate', {
method: 'POST',
headers: {'Content-Type': 'application/json; charset=utf-8'},
body: JSON.stringify({
text: document.getElementById(sourceElem).innerText,
source_language: sourceLang, source_language: sourceLang,
dest_language: destLang dest_language: destLang
}).done(function(response) { })
$(destElem).text(response['text']) })
}).fail(function() { const data = await response.json();
$(destElem).text("{{ _('Error: Could not contact server.') }}"); document.getElementById(destElem).innerText = data.text;
});
} }
$(function () {
var timer = null; function initialize_popovers() {
var xhr = null; const popups = document.getElementsByClassName('user_popup');
$('.user_popup').hover( for (let i = 0; i < popups.length; i++) {
function(event) { const popover = new bootstrap.Popover(popups[i], {
// mouse in event handler content: 'Loading...',
var elem = $(event.currentTarget); trigger: 'hover focus',
timer = setTimeout(function() { placement: 'right',
timer = null;
xhr = $.ajax(
'/user/' + elem.first().text().trim() + '/popup').done(
function(data) {
xhr = null;
elem.popover({
trigger: 'manual',
html: true, html: true,
animation: false, sanitize: false,
container: elem, delay: {show: 500, hide: 0},
content: data container: popups[i],
}).popover('show'); 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(); flask_moment_render_all();
} }
);
}, 1000);
},
function(event) {
// mouse out event handler
var elem = $(event.currentTarget);
if (timer) {
clearTimeout(timer);
timer = null;
}
else if (xhr) {
xhr.abort();
xhr = null;
}
else {
elem.popover('destroy');
}
}
);
}); });
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
function set_message_count(n) { function set_message_count(n) {
$('#message_count').text(n); const count = document.getElementById('message_count');
$('#message_count').css('visibility', n ? 'visible' : 'hidden'); count.innerText = n;
count.style.visibility = n ? 'visible' : 'hidden';
} }
function set_task_progress(task_id, progress) { function set_task_progress(task_id, progress) {
$('#' + task_id + '-progress').text(progress); const progressElement = document.getElementById(task_id + '-progress');
if (progressElement) {
progressElement.innerText = progress;
} }
}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
$(function() { function initialize_notifications() {
var since = 0; let since = 0;
setInterval(function() { setInterval(async function() {
$.ajax('{{ url_for('main.notifications') }}?since=' + since).done( const response = await fetch('{{ url_for('main.notifications') }}?since=' + since);
function(notifications) { const notifications = await response.json();
for (var i = 0; i < notifications.length; i++) { for (let i = 0; i < notifications.length; i++) {
switch (notifications[i].name) { switch (notifications[i].name) {
case 'unread_message_count': case 'unread_message_count':
set_message_count(notifications[i].data); set_message_count(notifications[i].data);
@ -162,10 +164,10 @@
} }
since = notifications[i].timestamp; since = notifications[i].timestamp;
} }
}
);
}, 10000); }, 10000);
}); }
document.addEventListener('DOMContentLoaded', initialize_notifications);
{% endif %} {% endif %}
</script> </script>
{% endblock %} </body>
</html>

View File

@ -0,0 +1,70 @@
{% macro form_field(field, autofocus) %}
{%- if field.type == 'BooleanField' %}
<div class="form-check mb-3">
{{ field(class='form-check-input') }}
{{ field.label(class='form-check-label') }}
</div>
{%- elif field.type == 'RadioField' %}
{{ field.label(class='form-label') }}
{%- for item in field %}
<div class="form-check{% if loop.last %} mb-3{% endif %}">
{{ item(class='form-check-input') }}
{{ item.label(class='form-check-label') }}
</div>
{%- endfor %}
{%- elif field.type == 'SelectField' %}
{{ field.label(class='form-label') }}
{{ field(class='form-select mb-3') }}
{%- elif field.type == 'TextAreaField' %}
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">{{ error }}</div>
{%- endfor %}
</div>
{%- elif field.type == 'SubmitField' %}
{{ field(class='btn btn-primary mb-3') }}
{%- else %}
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">{{ error }}</div>
{%- endfor %}
</div>
{%- endif %}
{% endmacro %}
{% macro quick_form(form, action="", method="post", id="", novalidate=False) %}
<form novalidate
{%- if action != None %} action="{{ action }}"{% endif -%}
{%- if method %} method="{{ method }}"{% endif %}
{%- if id %} id="{{ id }}"{% endif -%}
{%- if novalidate %} novalidate{% endif -%}>
{{ form.hidden_tag() }}
{%- for field, errors in form.errors.items() %}
{%- if form[field].widget.input_type == 'hidden' %}
{%- for error in errors %}
<div class="invalid-feedback">{{ error }}</div>
{%- 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 %}
</form>
{% endmacro %}

View File

@ -1,11 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %} {% import "bootstrap_wtf.html" as wtf %}
{% block app_content %} {% block content %}
<h1>{{ _('Edit Profile') }}</h1> <h1>{{ _('Edit Profile') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,3 +1,6 @@
<!doctype html>
<html>
<body>
<p>Dear {{ user.username }},</p> <p>Dear {{ user.username }},</p>
<p> <p>
To reset your password To reset your password
@ -10,3 +13,5 @@
<p>If you have not requested a password reset simply ignore this message.</p> <p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p> <p>Sincerely,</p>
<p>The Microblog Team</p> <p>The Microblog Team</p>
</body>
</html>

View File

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block app_content %} {% block content %}
<h1>{{ _('Not Found') }}</h1> <h1>{{ _('Not Found') }}</h1>
<p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p> <p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p>
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block app_content %} {% block content %}
<h1>{{ _('An unexpected error has occurred') }}</h1> <h1>{{ _('An unexpected error has occurred') }}</h1>
<p>{{ _('The administrator has been notified. Sorry for the inconvenience!') }}</p> <p>{{ _('The administrator has been notified. Sorry for the inconvenience!') }}</p>
<p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p> <p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p>

View File

@ -1,24 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %} {% import "bootstrap_wtf.html" as wtf %}
{% block app_content %} {% block content %}
<h1>{{ _('Hi, %(username)s!', username=current_user.username) }}</h1> <h1>{{ _('Hi, %(username)s!', username=current_user.username) }}</h1>
{% if form %} {% if form %}
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
<br>
{% endif %} {% endif %}
{% for post in posts %} {% for post in posts %}
{% include '_post.html' %} {% include '_post.html' %}
{% endfor %} {% endfor %}
<nav aria-label="..."> <nav aria-label="Post navigation">
<ul class="pager"> <ul class="pagination">
<li class="previous{% if not prev_url %} disabled{% endif %}"> <li class="page-item{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}"> <a class="page-link" href="#">
<span aria-hidden="true">&larr;</span> {{ _('Newer posts') }} <span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
</a> </a>
</li> </li>
<li class="next{% if not next_url %} disabled{% endif %}"> <li class="page-item{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}"> <a class="page-link" href="#">
{{ _('Older posts') }} <span aria-hidden="true">&rarr;</span> {{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
</a> </a>
</li> </li>

View File

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

View File

@ -1,20 +1,20 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block app_content %} {% block content %}
<h1>{{ _('Search Results') }}</h1> <h1>{{ _('Search Results') }}</h1>
{% for post in posts %} {% for post in posts %}
{% include '_post.html' %} {% include '_post.html' %}
{% endfor %} {% endfor %}
<nav aria-label="..."> <nav aria-label="Post navigation">
<ul class="pager"> <ul class="pagination">
<li class="previous{% if not prev_url %} disabled{% endif %}"> <li class="page-item{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}"> <a class="page-link" href="#">
<span aria-hidden="true">&larr;</span> {{ _('Previous results') }} <span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
</a> </a>
</li> </li>
<li class="next{% if not next_url %} disabled{% endif %}"> <li class="page-item{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}"> <a class="page-link" href="#">
{{ _('Next results') }} <span aria-hidden="true">&rarr;</span> {{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
</a> </a>
</li> </li>
</ul> </ul>

View File

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

View File

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block app_content %} {% block content %}
<table class="table table-hover"> <table class="table table-hover">
<tr> <tr>
<td width="256px"><img src="{{ user.avatar(256) }}"></td> <td width="256px"><img src="{{ user.avatar(256) }}"></td>
@ -10,7 +10,7 @@
{% if user.last_seen %} {% if user.last_seen %}
<p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('LLL') }}</p> <p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('LLL') }}</p>
{% endif %} {% endif %}
<p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p> <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.following.count()) }}</p>
{% if user == current_user %} {% if user == current_user %}
<p><a href="{{ url_for('main.edit_profile') }}">{{ _('Edit your profile') }}</a></p> <p><a href="{{ url_for('main.edit_profile') }}">{{ _('Edit your profile') }}</a></p>
{% if not current_user.get_task_in_progress('export_posts') %} {% if not current_user.get_task_in_progress('export_posts') %}
@ -20,14 +20,14 @@
<p> <p>
<form action="{{ url_for('main.follow', username=user.username) }}" method="post"> <form action="{{ url_for('main.follow', username=user.username) }}" method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.submit(value=_('Follow'), class_='btn btn-default') }} {{ form.submit(value=_('Follow'), class_='btn btn-primary') }}
</form> </form>
</p> </p>
{% else %} {% else %}
<p> <p>
<form action="{{ url_for('main.unfollow', username=user.username) }}" method="post"> <form action="{{ url_for('main.unfollow', username=user.username) }}" method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.submit(value=_('Unfollow'), class_='btn btn-default') }} {{ form.submit(value=_('Unfollow'), class_='btn btn-primary') }}
</form> </form>
</p> </p>
{% endif %} {% endif %}
@ -40,15 +40,15 @@
{% for post in posts %} {% for post in posts %}
{% include '_post.html' %} {% include '_post.html' %}
{% endfor %} {% endfor %}
<nav aria-label="..."> <nav aria-label="Post navigation">
<ul class="pager"> <ul class="pagination">
<li class="previous{% if not prev_url %} disabled{% endif %}"> <li class="page-item{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}"> <a class="page-link" href="#">
<span aria-hidden="true">&larr;</span> {{ _('Newer posts') }} <span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
</a> </a>
</li> </li>
<li class="next{% if not next_url %} disabled{% endif %}"> <li class="page-item{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}"> <a class="page-link" href="#">
{{ _('Older posts') }} <span aria-hidden="true">&rarr;</span> {{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
</a> </a>
</li> </li>

View File

@ -1,32 +1,27 @@
<table class="table"> <div>
<tr> <img src="{{ user.avatar(64) }}" style="margin: 5px; float: left">
<td width="64" style="border: 0px;"><img src="{{ user.avatar(64) }}"></td>
<td style="border: 0px;">
<p><a href="{{ url_for('main.user', username=user.username) }}">{{ user.username }}</a></p> <p><a href="{{ url_for('main.user', username=user.username) }}">{{ user.username }}</a></p>
<small>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
<div class="clearfix"></div>
{% if user.last_seen %} {% if user.last_seen %}
<p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}</p> <p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}</p>
{% endif %} {% endif %}
<p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p> <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.following.count()) }}</p>
{% if user != current_user %} {% if user != current_user %}
{% if not current_user.is_following(user) %} {% if not current_user.is_following(user) %}
<p> <p>
<form action="{{ url_for('main.follow', username=user.username) }}" method="post"> <form action="{{ url_for('main.follow', username=user.username) }}" method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.submit(value=_('Follow'), class_='btn btn-default btn-sm') }} {{ form.submit(value=_('Follow'), class_='btn btn-outline-primary btn-sm') }}
</form> </form>
</p> </p>
{% else %} {% else %}
<p> <p>
<form action="{{ url_for('main.unfollow', username=user.username) }}" method="post"> <form action="{{ url_for('main.unfollow', username=user.username) }}" method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.submit(value=_('Unfollow'), class_='btn btn-default btm-sm') }} {{ form.submit(value=_('Unfollow'), class_='btn btn-outline-primary btn-sm') }}
</form> </form>
</p> </p>
{% endif %} {% endif %}
{% endif %} {% endif %}
</small> </div>
</td>
</tr>
</table>

View File

@ -10,7 +10,8 @@ def translate(text, source_language, dest_language):
return _('Error: the translation service is not configured.') return _('Error: the translation service is not configured.')
auth = { auth = {
'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY'], 'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY'],
'Ocp-Apim-Subscription-Region': 'westus2'} 'Ocp-Apim-Subscription-Region': 'westus'
}
r = requests.post( r = requests.post(
'https://api.cognitive.microsofttranslator.com' 'https://api.cognitive.microsofttranslator.com'
'/translate?api-version=3.0&from={}&to={}'.format( '/translate?api-version=3.0&from={}&to={}'.format(

View File

@ -1,3 +1,2 @@
[python: app/**.py] [python: app/**.py]
[jinja2: app/templates/**.html] [jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

@ -9,5 +9,4 @@ while true; do
echo Deploy command failed, retrying in 5 secs... echo Deploy command failed, retrying in 5 secs...
sleep 5 sleep 5
done done
flask translate compile
exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app

View File

@ -10,7 +10,6 @@ class Config(object):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace( SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace(
'postgres://', 'postgresql://') or \ 'postgres://', 'postgresql://') or \
'sqlite:///' + os.path.join(basedir, 'app.db') 'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT')
MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)

View File

@ -1,8 +1,7 @@
from app import create_app, db, cli from app import create_app, db
from app.models import User, Post, Message, Notification, Task from app.models import User, Post, Message, Notification, Task
app = create_app() app = create_app()
cli.register(app)
@app.shell_context_processor @app.shell_context_processor

View File

@ -18,11 +18,15 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('post', sa.Column('language', sa.String(length=5), nullable=True)) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column('post', 'language') with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_column('language')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -18,13 +18,17 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('about_me', sa.String(length=140), nullable=True)) with op.batch_alter_table('user', schema=None) as batch_op:
op.add_column('user', sa.Column('last_seen', sa.DateTime(), nullable=True)) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'last_seen') with op.batch_alter_table('user', schema=None) as batch_op:
op.drop_column('user', 'about_me') batch_op.drop_column('last_seen')
batch_op.drop_column('about_me')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -26,12 +26,18 @@ def upgrade():
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_post_timestamp'), 'post', ['timestamp'], unique=False) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_post_timestamp'), table_name='post') 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') op.drop_table('post')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -19,10 +19,11 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('followers', op.create_table('followers',
sa.Column('follower_id', sa.Integer(), nullable=True), sa.Column('follower_id', sa.Integer(), nullable=False),
sa.Column('followed_id', sa.Integer(), nullable=True), sa.Column('followed_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ), sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ) sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('follower_id', 'followed_id')
) )
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -28,14 +28,26 @@ def upgrade():
sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ), sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_message_timestamp'), 'message', ['timestamp'], unique=False)
op.add_column('user', sa.Column('last_message_read_time', sa.DateTime(), nullable=True)) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'last_message_read_time') with op.batch_alter_table('user', schema=None) as batch_op:
op.drop_index(op.f('ix_message_timestamp'), table_name='message') batch_op.drop_column('last_message_read_time')
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_notification_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') op.drop_table('message')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -25,14 +25,18 @@ def upgrade():
sa.Column('password_hash', sa.String(length=128), nullable=True), sa.Column('password_hash', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) with op.batch_alter_table('user', schema=None) as batch_op:
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_username'), table_name='user') with op.batch_alter_table('user', schema=None) as batch_op:
op.drop_index(op.f('ix_user_email'), table_name='user') batch_op.drop_index(batch_op.f('ix_user_username'))
batch_op.drop_index(batch_op.f('ix_user_email'))
op.drop_table('user') op.drop_table('user')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -27,14 +27,20 @@ def upgrade():
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_notification_name'), 'notification', ['name'], unique=False)
op.create_index(op.f('ix_notification_timestamp'), 'notification', ['timestamp'], unique=False) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_notification_timestamp'), table_name='notification') with op.batch_alter_table('notification', schema=None) as batch_op:
op.drop_index(op.f('ix_notification_name'), table_name='notification') 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') op.drop_table('notification')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -1,49 +1,56 @@
alembic==1.6.5 aiosmtpd==1.4.4.post2
Babel==2.9.1 alembic==1.10.4
blinker==1.4 async-timeout==4.0.2
certifi==2021.5.30 atpublic==3.1.1
chardet==4.0.0 attrs==23.1.0
click==8.0.1 Babel==2.12.1
dnspython==2.1.0 blinker==1.6.2
dominate==2.6.0 certifi==2022.12.7
elasticsearch==7.13.3 charset-normalizer==3.1.0
email-validator==1.1.3 click==8.1.3
Flask==2.0.1 defusedxml==0.7.1
Flask-Babel==2.0.0 dnspython==2.3.0
Flask-Bootstrap==3.3.7.1 elastic-transport==8.4.0
Flask-HTTPAuth==4.4.0 elasticsearch==8.7.0
Flask-Login==0.5.0 email-validator==2.0.0.post2
Flask==2.3.2
flask-babel==3.1.0
Flask-HTTPAuth==4.8.0
Flask-Login==0.6.2
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-Migrate==3.0.1 Flask-Migrate==4.0.4
Flask-Moment==1.0.1 Flask-Moment==1.0.5
Flask-SQLAlchemy==2.5.1 Flask-SQLAlchemy==3.0.3
Flask-WTF==0.15.1 Flask-WTF==1.1.1
greenlet==1.1.0 greenlet==2.0.2
httpie==2.4.0 httpie==3.2.1
idna==2.10 idna==3.4
itsdangerous==2.0.1 itsdangerous==2.1.2
Jinja2==3.0.1 Jinja2==3.1.2
langdetect==1.0.9 langdetect==1.0.9
Mako==1.1.4 Mako==1.2.4
MarkupSafe==2.0.1 markdown-it-py==2.2.0
Pygments==2.9.0 MarkupSafe==2.1.2
PyJWT==2.1.0 mdurl==0.1.2
multidict==6.0.4
packaging==23.1
Pygments==2.15.1
PyJWT==2.6.0
PySocks==1.7.1 PySocks==1.7.1
python-dateutil==2.8.1 python-dotenv==1.0.0
python-dotenv==0.18.0 pytz==2023.3
python-editor==1.0.4 redis==4.5.4
pytz==2021.1 requests==2.29.0
redis==3.5.3 requests-toolbelt==1.0.0
requests==2.25.1 rich==13.3.5
requests-toolbelt==0.9.1 rq==1.14.0
rq==1.9.0
six==1.16.0 six==1.16.0
SQLAlchemy==1.4.20 SQLAlchemy==2.0.10
urllib3==1.26.6 typing_extensions==4.5.0
visitor==0.1.3 urllib3==1.26.15
Werkzeug==2.0.1 Werkzeug==2.3.3
WTForms==2.3.3 WTForms==3.0.1
# requirements for Heroku # requirements for Heroku
#psycopg2==2.9.1 #psycopg2-binary==2.9.6
#gunicorn==20.1.0 #gunicorn==20.1.0

View File

@ -42,21 +42,21 @@ class UserModelCase(unittest.TestCase):
db.session.add(u1) db.session.add(u1)
db.session.add(u2) db.session.add(u2)
db.session.commit() db.session.commit()
self.assertEqual(u1.followed.all(), []) self.assertEqual(u1.following.all(), [])
self.assertEqual(u1.followers.all(), []) self.assertEqual(u1.followers.all(), [])
u1.follow(u2) u1.follow(u2)
db.session.commit() db.session.commit()
self.assertTrue(u1.is_following(u2)) self.assertTrue(u1.is_following(u2))
self.assertEqual(u1.followed.count(), 1) self.assertEqual(u1.following.count(), 1)
self.assertEqual(u1.followed.first().username, 'susan') self.assertEqual(u1.following.first().username, 'susan')
self.assertEqual(u2.followers.count(), 1) self.assertEqual(u2.followers.count(), 1)
self.assertEqual(u2.followers.first().username, 'john') self.assertEqual(u2.followers.first().username, 'john')
u1.unfollow(u2) u1.unfollow(u2)
db.session.commit() db.session.commit()
self.assertFalse(u1.is_following(u2)) self.assertFalse(u1.is_following(u2))
self.assertEqual(u1.followed.count(), 0) self.assertEqual(u1.following.count(), 0)
self.assertEqual(u2.followers.count(), 0) self.assertEqual(u2.followers.count(), 0)
def test_follow_posts(self): def test_follow_posts(self):
@ -87,11 +87,11 @@ class UserModelCase(unittest.TestCase):
u3.follow(u4) # mary follows david u3.follow(u4) # mary follows david
db.session.commit() db.session.commit()
# check the followed posts of each user # check the following posts of each user
f1 = u1.followed_posts().all() f1 = u1.following_posts().all()
f2 = u2.followed_posts().all() f2 = u2.following_posts().all()
f3 = u3.followed_posts().all() f3 = u3.following_posts().all()
f4 = u4.followed_posts().all() f4 = u4.following_posts().all()
self.assertEqual(f1, [p2, p4, p1]) self.assertEqual(f1, [p2, p4, p1])
self.assertEqual(f2, [p2, p3]) self.assertEqual(f2, [p2, p3])
self.assertEqual(f3, [p3, p4]) self.assertEqual(f3, [p3, p4])