├── .gitattributes
├── .flaskenv
├── migrations
├── README
├── script.py.mako
├── alembic.ini
├── versions
│ ├── 2b017edaa91f_add_language_to_posts.py
│ ├── ae346256b650_followers.py
│ ├── 834b1a697901_user_tokens.py
│ ├── 37f06a334dbf_new_fields_in_user_model.py
│ ├── c81bac34faab_tasks.py
│ ├── e517276bb1c2_users_table.py
│ ├── 780739b227a7_posts_table.py
│ ├── f7ac3d27bb1d_notifications.py
│ └── d049de007ccf_private_messages.py
└── env.py
├── babel.cfg
├── app
├── static
│ └── loading.gif
├── auth
│ ├── __init__.py
│ ├── email.py
│ ├── forms.py
│ └── routes.py
├── main
│ ├── __init__.py
│ ├── forms.py
│ └── routes.py
├── api
│ ├── __init__.py
│ ├── tokens.py
│ ├── errors.py
│ ├── auth.py
│ └── users.py
├── errors
│ ├── __init__.py
│ └── handlers.py
├── templates
│ ├── email
│ │ ├── export_posts.txt
│ │ ├── export_posts.html
│ │ ├── reset_password.txt
│ │ └── reset_password.html
│ ├── errors
│ │ ├── 404.html
│ │ └── 500.html
│ ├── auth
│ │ ├── register.html
│ │ ├── reset_password.html
│ │ ├── reset_password_request.html
│ │ └── login.html
│ ├── edit_profile.html
│ ├── send_message.html
│ ├── messages.html
│ ├── search.html
│ ├── index.html
│ ├── user_popup.html
│ ├── _post.html
│ ├── bootstrap_wtf.html
│ ├── user.html
│ └── base.html
├── email.py
├── translate.py
├── search.py
├── cli.py
├── tasks.py
├── __init__.py
├── translations
│ └── es
│ │ └── LC_MESSAGES
│ │ └── messages.po
└── models.py
├── Procfile
├── Vagrantfile
├── deployment
├── supervisor
│ ├── microblog.conf
│ └── microblog-tasks.conf
└── nginx
│ └── microblog
├── boot.sh
├── Dockerfile
├── microblog.py
├── .gitignore
├── README.md
├── config.py
├── LICENSE
├── requirements.txt
└── tests.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | *.sh text eol=lf
3 |
--------------------------------------------------------------------------------
/.flaskenv:
--------------------------------------------------------------------------------
1 | FLASK_APP=microblog.py
2 | FLASK_DEBUG=1
3 |
--------------------------------------------------------------------------------
/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
--------------------------------------------------------------------------------
/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: app/**.py]
2 | [jinja2: app/templates/**.html]
3 |
--------------------------------------------------------------------------------
/app/static/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/miguelgrinberg/microblog/HEAD/app/static/loading.gif
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: flask db upgrade; flask translate compile; gunicorn microblog:app
2 | worker: rq worker microblog-tasks
3 |
--------------------------------------------------------------------------------
/app/auth/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('auth', __name__)
4 |
5 | from app.auth import routes
6 |
--------------------------------------------------------------------------------
/app/main/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('main', __name__)
4 |
5 | from app.main import routes
6 |
--------------------------------------------------------------------------------
/app/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('api', __name__)
4 |
5 | from app.api import users, errors, tokens
6 |
--------------------------------------------------------------------------------
/app/errors/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('errors', __name__)
4 |
5 | from app.errors import handlers
6 |
--------------------------------------------------------------------------------
/app/templates/email/export_posts.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | Please find attached the archive of your posts that you requested.
4 |
5 | Sincerely,
6 |
7 | The Microblog Team
8 |
--------------------------------------------------------------------------------
/app/templates/email/export_posts.html:
--------------------------------------------------------------------------------
1 |
Dear {{ user.username }},
2 | Please find attached the archive of your posts that you requested.
3 | Sincerely,
4 | The Microblog Team
5 |
--------------------------------------------------------------------------------
/app/templates/errors/404.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 | {{ _('Not Found') }}
5 | {{ _('Back') }}
6 | {% endblock %}
7 |
--------------------------------------------------------------------------------
/app/templates/auth/register.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Register') }}
6 | {{ wtf.quick_form(form) }}
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/app/templates/edit_profile.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Edit Profile') }}
6 | {{ wtf.quick_form(form) }}
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | Vagrant.configure("2") do |config|
2 | config.vm.box = "ubuntu/jammy64"
3 | config.vm.network "private_network", ip: "192.168.56.10"
4 | config.vm.provider "virtualbox" do |vb|
5 | vb.memory = "2048"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/templates/auth/reset_password.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Reset Your Password') }}
6 | {{ wtf.quick_form(form) }}
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/app/templates/auth/reset_password_request.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Reset Password') }}
6 | {{ wtf.quick_form(form) }}
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/app/templates/send_message.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Send Message to %(recipient)s', recipient=recipient) }}
6 | {{ wtf.quick_form(form) }}
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/deployment/supervisor/microblog.conf:
--------------------------------------------------------------------------------
1 | [program:microblog]
2 | command=/home/ubuntu/microblog/venv/bin/gunicorn -b localhost:8000 -w 4 microblog:app
3 | directory=/home/ubuntu/microblog
4 | user=ubuntu
5 | autostart=true
6 | autorestart=true
7 | stopasgroup=true
8 | killasgroup=true
9 |
--------------------------------------------------------------------------------
/deployment/supervisor/microblog-tasks.conf:
--------------------------------------------------------------------------------
1 | [program:microblog-tasks]
2 | command=/home/ubuntu/microblog/venv/bin/rq worker microblog-tasks
3 | numprocs=1
4 | directory=/home/ubuntu/microblog
5 | user=ubuntu
6 | autostart=true
7 | autorestart=true
8 | stopasgroup=true
9 | killasgroup=true
10 |
--------------------------------------------------------------------------------
/app/templates/errors/500.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 | {{ _('An unexpected error has occurred') }}
5 | {{ _('The administrator has been notified. Sorry for the inconvenience!') }}
6 | {{ _('Back') }}
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/app/templates/email/reset_password.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | To reset your password click on the following link:
4 |
5 | {{ url_for('auth.reset_password', token=token, _external=True) }}
6 |
7 | If you have not requested a password reset simply ignore this message.
8 |
9 | Sincerely,
10 |
11 | The Microblog Team
12 |
--------------------------------------------------------------------------------
/boot.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # this script is used to boot a Docker container
3 | while true; do
4 | flask db upgrade
5 | if [[ "$?" == "0" ]]; then
6 | break
7 | fi
8 | echo Deploy command failed, retrying in 5 secs...
9 | sleep 5
10 | done
11 | exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app
12 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:slim
2 |
3 | COPY requirements.txt requirements.txt
4 | RUN pip install -r requirements.txt
5 | RUN pip install gunicorn pymysql cryptography
6 |
7 | COPY app app
8 | COPY migrations migrations
9 | COPY microblog.py config.py boot.sh ./
10 | RUN chmod a+x boot.sh
11 |
12 | ENV FLASK_APP microblog.py
13 | RUN flask translate compile
14 |
15 | EXPOSE 5000
16 | ENTRYPOINT ["./boot.sh"]
17 |
--------------------------------------------------------------------------------
/microblog.py:
--------------------------------------------------------------------------------
1 | import sqlalchemy as sa
2 | import sqlalchemy.orm as so
3 | from app import create_app, db
4 | from app.models import User, Post, Message, Notification, Task
5 |
6 | app = create_app()
7 |
8 |
9 | @app.shell_context_processor
10 | def make_shell_context():
11 | return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post,
12 | 'Message': Message, 'Notification': Notification, 'Task': Task}
13 |
--------------------------------------------------------------------------------
/app/templates/auth/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Sign In') }}
6 | {{ wtf.quick_form(form) }}
7 | {{ _('New User?') }} {{ _('Click to Register!') }}
8 |
9 | {{ _('Forgot Your Password?') }}
10 | {{ _('Click to Reset It') }}
11 |
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[cod]
2 |
3 | # C extensions
4 | *.so
5 |
6 | # Packages
7 | *.egg
8 | *.egg-info
9 | dist
10 | build
11 | eggs
12 | parts
13 | bin
14 | var
15 | sdist
16 | develop-eggs
17 | .installed.cfg
18 | lib
19 | lib64
20 | __pycache__
21 |
22 | # Installer logs
23 | pip-log.txt
24 |
25 | # Unit test / coverage reports
26 | .coverage
27 | .tox
28 | nosetests.xml
29 |
30 | # Translations
31 | *.mo
32 |
33 | # Mr Developer
34 | .mr.developer.cfg
35 | .project
36 | .pydevproject
37 |
38 | venv
39 | app.db
40 | microblog.log*
41 |
--------------------------------------------------------------------------------
/app/api/tokens.py:
--------------------------------------------------------------------------------
1 | from app import db
2 | from app.api import bp
3 | from app.api.auth import basic_auth, token_auth
4 |
5 |
6 | @bp.route('/tokens', methods=['POST'])
7 | @basic_auth.login_required
8 | def get_token():
9 | token = basic_auth.current_user().get_token()
10 | db.session.commit()
11 | return {'token': token}
12 |
13 |
14 | @bp.route('/tokens', methods=['DELETE'])
15 | @token_auth.login_required
16 | def revoke_token():
17 | token_auth.current_user().revoke_token()
18 | db.session.commit()
19 | return '', 204
20 |
--------------------------------------------------------------------------------
/app/api/errors.py:
--------------------------------------------------------------------------------
1 | from werkzeug.http import HTTP_STATUS_CODES
2 | from werkzeug.exceptions import HTTPException
3 | from app.api import bp
4 |
5 |
6 | def error_response(status_code, message=None):
7 | payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')}
8 | if message:
9 | payload['message'] = message
10 | return payload, status_code
11 |
12 |
13 | def bad_request(message):
14 | return error_response(400, message)
15 |
16 |
17 | @bp.errorhandler(HTTPException)
18 | def handle_exception(e):
19 | return error_response(e.code)
20 |
--------------------------------------------------------------------------------
/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Microblog!
2 |
3 | This is an example application featured in my [Flask Mega-Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world). See the tutorial for instructions on how to work with it.
4 |
5 | The version of the application featured in this repository corresponds to the 2024 edition of the Flask Mega-Tutorial. You can find the 2018 and 2021 versions of the code [here](https://github.com/miguelgrinberg/microblog-2018). And if for any strange reason you are interested in the original code, dating back to 2012, that is [here](https://github.com/miguelgrinberg/microblog-2012).
6 |
--------------------------------------------------------------------------------
/app/auth/email.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, current_app
2 | from flask_babel import _
3 | from app.email import send_email
4 |
5 |
6 | def send_password_reset_email(user):
7 | token = user.get_reset_password_token()
8 | send_email(_('[Microblog] Reset Your Password'),
9 | sender=current_app.config['ADMINS'][0],
10 | recipients=[user.email],
11 | text_body=render_template('email/reset_password.txt',
12 | user=user, token=token),
13 | html_body=render_template('email/reset_password.html',
14 | user=user, token=token))
15 |
--------------------------------------------------------------------------------
/app/templates/email/reset_password.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Dear {{ user.username }},
5 |
6 | To reset your password
7 |
8 | click here
9 | .
10 |
11 | Alternatively, you can paste the following link in your browser's address bar:
12 | {{ url_for('auth.reset_password', token=token, _external=True) }}
13 | If you have not requested a password reset simply ignore this message.
14 | Sincerely,
15 | The Microblog Team
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/errors/handlers.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, request
2 | from app import db
3 | from app.errors import bp
4 | from app.api.errors import error_response as api_error_response
5 |
6 |
7 | def wants_json_response():
8 | return request.accept_mimetypes['application/json'] >= \
9 | request.accept_mimetypes['text/html']
10 |
11 |
12 | @bp.app_errorhandler(404)
13 | def not_found_error(error):
14 | if wants_json_response():
15 | return api_error_response(404)
16 | return render_template('errors/404.html'), 404
17 |
18 |
19 | @bp.app_errorhandler(500)
20 | def internal_error(error):
21 | db.session.rollback()
22 | if wants_json_response():
23 | return api_error_response(500)
24 | return render_template('errors/500.html'), 500
25 |
--------------------------------------------------------------------------------
/app/email.py:
--------------------------------------------------------------------------------
1 | from threading import Thread
2 | from flask import current_app
3 | from flask_mail import Message
4 | from app import mail
5 |
6 |
7 | def send_async_email(app, msg):
8 | with app.app_context():
9 | mail.send(msg)
10 |
11 |
12 | def send_email(subject, sender, recipients, text_body, html_body,
13 | attachments=None, sync=False):
14 | msg = Message(subject, sender=sender, recipients=recipients)
15 | msg.body = text_body
16 | msg.html = html_body
17 | if attachments:
18 | for attachment in attachments:
19 | msg.attach(*attachment)
20 | if sync:
21 | mail.send(msg)
22 | else:
23 | Thread(target=send_async_email,
24 | args=(current_app._get_current_object(), msg)).start()
25 |
--------------------------------------------------------------------------------
/app/api/auth.py:
--------------------------------------------------------------------------------
1 | import sqlalchemy as sa
2 | from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
3 | from app import db
4 | from app.models import User
5 | from app.api.errors import error_response
6 |
7 | basic_auth = HTTPBasicAuth()
8 | token_auth = HTTPTokenAuth()
9 |
10 |
11 | @basic_auth.verify_password
12 | def verify_password(username, password):
13 | user = db.session.scalar(sa.select(User).where(User.username == username))
14 | if user and user.check_password(password):
15 | return user
16 |
17 |
18 | @basic_auth.error_handler
19 | def basic_auth_error(status):
20 | return error_response(status)
21 |
22 |
23 | @token_auth.verify_token
24 | def verify_token(token):
25 | return User.check_token(token) if token else None
26 |
27 |
28 | @token_auth.error_handler
29 | def token_auth_error(status):
30 | return error_response(status)
31 |
--------------------------------------------------------------------------------
/app/templates/messages.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 | {{ _('Messages') }}
5 | {% for post in messages %}
6 | {% include '_post.html' %}
7 | {% endfor %}
8 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/app/templates/search.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 | {{ _('Search Results') }}
5 | {% for post in posts %}
6 | {% include '_post.html' %}
7 | {% endfor %}
8 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/app/translate.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from flask import current_app
3 | from flask_babel import _
4 |
5 |
6 | def translate(text, source_language, dest_language):
7 | if 'MS_TRANSLATOR_KEY' not in current_app.config or \
8 | not current_app.config['MS_TRANSLATOR_KEY']:
9 | return _('Error: the translation service is not configured.')
10 | auth = {
11 | 'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY'],
12 | 'Ocp-Apim-Subscription-Region': 'westus'
13 | }
14 | r = requests.post(
15 | 'https://api.cognitive.microsofttranslator.com'
16 | '/translate?api-version=3.0&from={}&to={}'.format(
17 | source_language, dest_language), headers=auth, json=[
18 | {'Text': text}])
19 | if r.status_code != 200:
20 | return _('Error: the translation service failed.')
21 | return r.json()[0]['translations'][0]['text']
22 |
--------------------------------------------------------------------------------
/migrations/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # template used to generate migration files
5 | # file_template = %%(rev)s_%%(slug)s
6 |
7 | # set to 'true' to run the environment during
8 | # the 'revision' command, regardless of autogenerate
9 | # revision_environment = false
10 |
11 |
12 | # Logging configuration
13 | [loggers]
14 | keys = root,sqlalchemy,alembic
15 |
16 | [handlers]
17 | keys = console
18 |
19 | [formatters]
20 | keys = generic
21 |
22 | [logger_root]
23 | level = WARN
24 | handlers = console
25 | qualname =
26 |
27 | [logger_sqlalchemy]
28 | level = WARN
29 | handlers =
30 | qualname = sqlalchemy.engine
31 |
32 | [logger_alembic]
33 | level = INFO
34 | handlers =
35 | qualname = alembic
36 |
37 | [handler_console]
38 | class = StreamHandler
39 | args = (sys.stderr,)
40 | level = NOTSET
41 | formatter = generic
42 |
43 | [formatter_generic]
44 | format = %(levelname)-5.5s [%(name)s] %(message)s
45 | datefmt = %H:%M:%S
46 |
--------------------------------------------------------------------------------
/app/search.py:
--------------------------------------------------------------------------------
1 | from flask import current_app
2 |
3 |
4 | def add_to_index(index, model):
5 | if not current_app.elasticsearch:
6 | return
7 | payload = {}
8 | for field in model.__searchable__:
9 | payload[field] = getattr(model, field)
10 | current_app.elasticsearch.index(index=index, id=model.id, document=payload)
11 |
12 |
13 | def remove_from_index(index, model):
14 | if not current_app.elasticsearch:
15 | return
16 | current_app.elasticsearch.delete(index=index, id=model.id)
17 |
18 |
19 | def query_index(index, query, page, per_page):
20 | if not current_app.elasticsearch:
21 | return [], 0
22 | search = current_app.elasticsearch.search(
23 | index=index,
24 | query={'multi_match': {'query': query, 'fields': ['*']}},
25 | from_=(page - 1) * per_page,
26 | size=per_page)
27 | ids = [int(hit['_id']) for hit in search['hits']['hits']]
28 | return ids, search['hits']['total']['value']
29 |
--------------------------------------------------------------------------------
/migrations/versions/2b017edaa91f_add_language_to_posts.py:
--------------------------------------------------------------------------------
1 | """add language to posts
2 |
3 | Revision ID: 2b017edaa91f
4 | Revises: ae346256b650
5 | Create Date: 2017-10-04 22:48:34.494465
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '2b017edaa91f'
14 | down_revision = 'ae346256b650'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | with op.batch_alter_table('post', schema=None) as batch_op:
22 | batch_op.add_column(sa.Column('language', sa.String(length=5), nullable=True))
23 |
24 | # ### end Alembic commands ###
25 |
26 |
27 | def downgrade():
28 | # ### commands auto generated by Alembic - please adjust! ###
29 | with op.batch_alter_table('post', schema=None) as batch_op:
30 | batch_op.drop_column('language')
31 |
32 | # ### end Alembic commands ###
33 |
--------------------------------------------------------------------------------
/app/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "bootstrap_wtf.html" as wtf %}
3 |
4 | {% block content %}
5 | {{ _('Hi, %(username)s!', username=current_user.username) }}
6 | {% if form %}
7 | {{ wtf.quick_form(form) }}
8 | {% endif %}
9 | {% for post in posts %}
10 | {% include '_post.html' %}
11 | {% endfor %}
12 |
26 | {% endblock %}
27 |
--------------------------------------------------------------------------------
/migrations/versions/ae346256b650_followers.py:
--------------------------------------------------------------------------------
1 | """followers
2 |
3 | Revision ID: ae346256b650
4 | Revises: 37f06a334dbf
5 | Create Date: 2017-09-17 15:41:30.211082
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'ae346256b650'
14 | down_revision = '37f06a334dbf'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('followers',
22 | sa.Column('follower_id', sa.Integer(), nullable=False),
23 | sa.Column('followed_id', sa.Integer(), nullable=False),
24 | sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ),
25 | sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ),
26 | sa.PrimaryKeyConstraint('follower_id', 'followed_id')
27 | )
28 | # ### end Alembic commands ###
29 |
30 |
31 | def downgrade():
32 | # ### commands auto generated by Alembic - please adjust! ###
33 | op.drop_table('followers')
34 | # ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/migrations/versions/834b1a697901_user_tokens.py:
--------------------------------------------------------------------------------
1 | """user tokens
2 |
3 | Revision ID: 834b1a697901
4 | Revises: c81bac34faab
5 | Create Date: 2017-11-05 18:41:07.996137
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '834b1a697901'
14 | down_revision = 'c81bac34faab'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.add_column('user', sa.Column('token', sa.String(length=32), nullable=True))
22 | op.add_column('user', sa.Column('token_expiration', sa.DateTime(), nullable=True))
23 | op.create_index(op.f('ix_user_token'), 'user', ['token'], unique=True)
24 | # ### end Alembic commands ###
25 |
26 |
27 | def downgrade():
28 | # ### commands auto generated by Alembic - please adjust! ###
29 | op.drop_index(op.f('ix_user_token'), table_name='user')
30 | op.drop_column('user', 'token_expiration')
31 | op.drop_column('user', 'token')
32 | # ### end Alembic commands ###
33 |
--------------------------------------------------------------------------------
/migrations/versions/37f06a334dbf_new_fields_in_user_model.py:
--------------------------------------------------------------------------------
1 | """new fields in user model
2 |
3 | Revision ID: 37f06a334dbf
4 | Revises: 780739b227a7
5 | Create Date: 2017-09-14 10:54:13.865401
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '37f06a334dbf'
14 | down_revision = '780739b227a7'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | with op.batch_alter_table('user', schema=None) as batch_op:
22 | batch_op.add_column(sa.Column('about_me', sa.String(length=140), nullable=True))
23 | batch_op.add_column(sa.Column('last_seen', sa.DateTime(), nullable=True))
24 |
25 | # ### end Alembic commands ###
26 |
27 |
28 | def downgrade():
29 | # ### commands auto generated by Alembic - please adjust! ###
30 | with op.batch_alter_table('user', schema=None) as batch_op:
31 | batch_op.drop_column('last_seen')
32 | batch_op.drop_column('about_me')
33 |
34 | # ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from dotenv import load_dotenv
3 |
4 | basedir = os.path.abspath(os.path.dirname(__file__))
5 | load_dotenv(os.path.join(basedir, '.env'))
6 |
7 |
8 | class Config:
9 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
10 | SERVER_NAME = os.environ.get('SERVER_NAME')
11 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace(
12 | 'postgres://', 'postgresql://') or \
13 | 'sqlite:///' + os.path.join(basedir, 'app.db')
14 | LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT')
15 | MAIL_SERVER = os.environ.get('MAIL_SERVER')
16 | MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
17 | MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
18 | MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
19 | MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
20 | ADMINS = ['your-email@example.com']
21 | LANGUAGES = ['en', 'es']
22 | MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
23 | ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
24 | REDIS_URL = os.environ.get('REDIS_URL') or 'redis://'
25 | POSTS_PER_PAGE = 25
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Miguel Grinberg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
22 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiosmtpd==1.4.4.post2
2 | alembic==1.12.1
3 | atpublic==4.0
4 | attrs==23.1.0
5 | Babel==2.13.1
6 | blinker==1.7.0
7 | certifi==2023.11.17
8 | charset-normalizer==3.3.2
9 | click==8.1.7
10 | defusedxml==0.7.1
11 | dnspython==2.4.2
12 | elastic-transport==8.10.0
13 | elasticsearch==8.11.0
14 | email-validator==2.1.0.post1
15 | Flask==3.0.0
16 | flask-babel==4.0.0
17 | Flask-HTTPAuth==4.8.0
18 | Flask-Login==0.6.3
19 | Flask-Mail==0.9.1
20 | Flask-Migrate==4.0.5
21 | Flask-Moment==1.0.5
22 | Flask-SQLAlchemy==3.1.1
23 | Flask-WTF==1.2.1
24 | greenlet==3.0.1
25 | gunicorn==21.2.0
26 | httpie==3.2.2
27 | idna==3.4
28 | itsdangerous==2.1.2
29 | Jinja2==3.1.2
30 | langdetect==1.0.9
31 | Mako==1.3.0
32 | markdown-it-py==3.0.0
33 | MarkupSafe==2.1.3
34 | mdurl==0.1.2
35 | multidict==6.0.4
36 | packaging==23.2
37 | psycopg2-binary==2.9.9
38 | Pygments==2.17.1
39 | PyJWT==2.8.0
40 | PySocks==1.7.1
41 | python-dotenv==1.0.0
42 | pytz==2023.3.post1
43 | redis==5.0.1
44 | requests==2.31.0
45 | requests-toolbelt==1.0.0
46 | rich==13.7.0
47 | rq==1.15.1
48 | setuptools==68.2.2
49 | six==1.16.0
50 | SQLAlchemy==2.0.23
51 | typing_extensions==4.8.0
52 | urllib3==2.1.0
53 | Werkzeug==3.0.1
54 | WTForms==3.1.1
55 |
--------------------------------------------------------------------------------
/app/templates/user_popup.html:
--------------------------------------------------------------------------------
1 |
2 |
 }})
3 |
{{ user.username }}
4 | {% if user.about_me %}
{{ user.about_me }}
{% endif %}
5 |
6 | {% if user.last_seen %}
7 |
{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}
8 | {% endif %}
9 |
{{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_count()) }}
10 | {% if user != current_user %}
11 | {% if not current_user.is_following(user) %}
12 |
13 |
17 |
18 | {% else %}
19 |
20 |
24 |
25 | {% endif %}
26 | {% endif %}
27 |
28 |
--------------------------------------------------------------------------------
/deployment/nginx/microblog:
--------------------------------------------------------------------------------
1 | server {
2 | # listen on port 80 (http)
3 | listen 80;
4 | server_name _;
5 | location / {
6 | # redirect any requests to the same URL but on https
7 | return 301 https://$host$request_uri;
8 | }
9 | }
10 | server {
11 | # listen on port 443 (https)
12 | listen 443 ssl;
13 | server_name _;
14 |
15 | # location of the self-signed SSL certificate
16 | ssl_certificate /home/ubuntu/microblog/certs/cert.pem;
17 | ssl_certificate_key /home/ubuntu/microblog/certs/key.pem;
18 |
19 | # write access and error logs to /var/log
20 | access_log /var/log/microblog_access.log;
21 | error_log /var/log/microblog_error.log;
22 |
23 | location / {
24 | # forward application requests to the gunicorn server
25 | proxy_pass http://localhost:8000;
26 | proxy_redirect off;
27 | proxy_set_header Host $host;
28 | proxy_set_header X-Real-IP $remote_addr;
29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
30 | }
31 |
32 | location /static {
33 | # handle static files directly, without forwarding to the application
34 | alias /home/ubuntu/microblog/app/static;
35 | expires 30d;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/cli.py:
--------------------------------------------------------------------------------
1 | import os
2 | from flask import Blueprint
3 | import click
4 |
5 | bp = Blueprint('cli', __name__, cli_group=None)
6 |
7 |
8 | @bp.cli.group()
9 | def translate():
10 | """Translation and localization commands."""
11 | pass
12 |
13 |
14 | @translate.command()
15 | @click.argument('lang')
16 | def init(lang):
17 | """Initialize a new language."""
18 | if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
19 | raise RuntimeError('extract command failed')
20 | if os.system(
21 | 'pybabel init -i messages.pot -d app/translations -l ' + lang):
22 | raise RuntimeError('init command failed')
23 | os.remove('messages.pot')
24 |
25 |
26 | @translate.command()
27 | def update():
28 | """Update all languages."""
29 | if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
30 | raise RuntimeError('extract command failed')
31 | if os.system('pybabel update -i messages.pot -d app/translations'):
32 | raise RuntimeError('update command failed')
33 | os.remove('messages.pot')
34 |
35 |
36 | @translate.command()
37 | def compile():
38 | """Compile all languages."""
39 | if os.system('pybabel compile -d app/translations'):
40 | raise RuntimeError('compile command failed')
41 |
--------------------------------------------------------------------------------
/migrations/versions/c81bac34faab_tasks.py:
--------------------------------------------------------------------------------
1 | """tasks
2 |
3 | Revision ID: c81bac34faab
4 | Revises: f7ac3d27bb1d
5 | Create Date: 2017-11-23 10:56:49.599779
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'c81bac34faab'
14 | down_revision = 'f7ac3d27bb1d'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('task',
22 | sa.Column('id', sa.String(length=36), nullable=False),
23 | sa.Column('name', sa.String(length=128), nullable=False),
24 | sa.Column('description', sa.String(length=128), nullable=True),
25 | sa.Column('user_id', sa.Integer(), nullable=False),
26 | sa.Column('complete', sa.Boolean(), nullable=False),
27 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
28 | sa.PrimaryKeyConstraint('id')
29 | )
30 | op.create_index(op.f('ix_task_name'), 'task', ['name'], unique=False)
31 | # ### end Alembic commands ###
32 |
33 |
34 | def downgrade():
35 | # ### commands auto generated by Alembic - please adjust! ###
36 | op.drop_index(op.f('ix_task_name'), table_name='task')
37 | op.drop_table('task')
38 | # ### end Alembic commands ###
39 |
--------------------------------------------------------------------------------
/app/templates/_post.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | |
8 |
9 | {% set user_link %}
10 |
13 | {% endset %}
14 | {{ _('%(username)s said %(when)s',
15 | username=user_link, when=moment(post.timestamp).fromNow()) }}
16 |
17 | {{ post.body }}
18 | {% if post.language and post.language != g.locale %}
19 |
20 |
21 | {{ _('Translate') }}
26 |
27 | {% endif %}
28 | |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/migrations/versions/e517276bb1c2_users_table.py:
--------------------------------------------------------------------------------
1 | """users table
2 |
3 | Revision ID: e517276bb1c2
4 | Revises:
5 | Create Date: 2017-09-11 11:23:05.566844
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'e517276bb1c2'
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('user',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('username', sa.String(length=64), nullable=False),
24 | sa.Column('email', sa.String(length=120), nullable=False),
25 | sa.Column('password_hash', sa.String(length=256), nullable=True),
26 | sa.PrimaryKeyConstraint('id')
27 | )
28 | with op.batch_alter_table('user', schema=None) as batch_op:
29 | batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True)
30 | batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True)
31 |
32 | # ### end Alembic commands ###
33 |
34 |
35 | def downgrade():
36 | # ### commands auto generated by Alembic - please adjust! ###
37 | with op.batch_alter_table('user', schema=None) as batch_op:
38 | batch_op.drop_index(batch_op.f('ix_user_username'))
39 | batch_op.drop_index(batch_op.f('ix_user_email'))
40 |
41 | op.drop_table('user')
42 | # ### end Alembic commands ###
43 |
--------------------------------------------------------------------------------
/migrations/versions/780739b227a7_posts_table.py:
--------------------------------------------------------------------------------
1 | """posts table
2 |
3 | Revision ID: 780739b227a7
4 | Revises: e517276bb1c2
5 | Create Date: 2017-09-11 12:23:25.496587
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '780739b227a7'
14 | down_revision = 'e517276bb1c2'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('post',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('body', sa.String(length=140), nullable=False),
24 | sa.Column('timestamp', sa.DateTime(), nullable=False),
25 | sa.Column('user_id', sa.Integer(), nullable=False),
26 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
27 | sa.PrimaryKeyConstraint('id')
28 | )
29 | with op.batch_alter_table('post', schema=None) as batch_op:
30 | batch_op.create_index(batch_op.f('ix_post_timestamp'), ['timestamp'], unique=False)
31 | batch_op.create_index(batch_op.f('ix_post_user_id'), ['user_id'], unique=False)
32 |
33 | # ### end Alembic commands ###
34 |
35 |
36 | def downgrade():
37 | # ### commands auto generated by Alembic - please adjust! ###
38 | with op.batch_alter_table('post', schema=None) as batch_op:
39 | batch_op.drop_index(batch_op.f('ix_post_user_id'))
40 | batch_op.drop_index(batch_op.f('ix_post_timestamp'))
41 |
42 | op.drop_table('post')
43 | # ### end Alembic commands ###
44 |
--------------------------------------------------------------------------------
/migrations/versions/f7ac3d27bb1d_notifications.py:
--------------------------------------------------------------------------------
1 | """notifications
2 |
3 | Revision ID: f7ac3d27bb1d
4 | Revises: d049de007ccf
5 | Create Date: 2017-11-22 19:48:39.945858
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'f7ac3d27bb1d'
14 | down_revision = 'd049de007ccf'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('notification',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('name', sa.String(length=128), nullable=False),
24 | sa.Column('user_id', sa.Integer(), nullable=False),
25 | sa.Column('timestamp', sa.Float(), nullable=False),
26 | sa.Column('payload_json', sa.Text(), nullable=False),
27 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
28 | sa.PrimaryKeyConstraint('id')
29 | )
30 |
31 | with op.batch_alter_table('notification', schema=None) as batch_op:
32 | batch_op.create_index(batch_op.f('ix_notification_name'), ['name'], unique=False)
33 | batch_op.create_index(batch_op.f('ix_notification_timestamp'), ['timestamp'], unique=False)
34 | batch_op.create_index(batch_op.f('ix_notification_user_id'), ['user_id'], unique=False)
35 | # ### end Alembic commands ###
36 |
37 |
38 | def downgrade():
39 | # ### commands auto generated by Alembic - please adjust! ###
40 | with op.batch_alter_table('notification', schema=None) as batch_op:
41 | batch_op.drop_index(batch_op.f('ix_notification_user_id'))
42 | batch_op.drop_index(batch_op.f('ix_notification_timestamp'))
43 | batch_op.drop_index(batch_op.f('ix_notification_name'))
44 |
45 | op.drop_table('notification')
46 | # ### end Alembic commands ###
47 |
--------------------------------------------------------------------------------
/app/main/forms.py:
--------------------------------------------------------------------------------
1 | from flask import request
2 | from flask_wtf import FlaskForm
3 | from wtforms import StringField, SubmitField, TextAreaField
4 | from wtforms.validators import ValidationError, DataRequired, Length
5 | import sqlalchemy as sa
6 | from flask_babel import _, lazy_gettext as _l
7 | from app import db
8 | from app.models import User
9 |
10 |
11 | class EditProfileForm(FlaskForm):
12 | username = StringField(_l('Username'), validators=[DataRequired()])
13 | about_me = TextAreaField(_l('About me'),
14 | validators=[Length(min=0, max=140)])
15 | submit = SubmitField(_l('Submit'))
16 |
17 | def __init__(self, original_username, *args, **kwargs):
18 | super().__init__(*args, **kwargs)
19 | self.original_username = original_username
20 |
21 | def validate_username(self, username):
22 | if username.data != self.original_username:
23 | user = db.session.scalar(sa.select(User).where(
24 | User.username == username.data))
25 | if user is not None:
26 | raise ValidationError(_('Please use a different username.'))
27 |
28 |
29 | class EmptyForm(FlaskForm):
30 | submit = SubmitField('Submit')
31 |
32 |
33 | class PostForm(FlaskForm):
34 | post = TextAreaField(_l('Say something'), validators=[
35 | DataRequired(), Length(min=1, max=140)])
36 | submit = SubmitField(_l('Submit'))
37 |
38 |
39 | class SearchForm(FlaskForm):
40 | q = StringField(_l('Search'), validators=[DataRequired()])
41 |
42 | def __init__(self, *args, **kwargs):
43 | if 'formdata' not in kwargs:
44 | kwargs['formdata'] = request.args
45 | if 'meta' not in kwargs:
46 | kwargs['meta'] = {'csrf': False}
47 | super(SearchForm, self).__init__(*args, **kwargs)
48 |
49 |
50 | class MessageForm(FlaskForm):
51 | message = TextAreaField(_l('Message'), validators=[
52 | DataRequired(), Length(min=1, max=140)])
53 | submit = SubmitField(_l('Submit'))
54 |
--------------------------------------------------------------------------------
/app/tasks.py:
--------------------------------------------------------------------------------
1 | import json
2 | import sys
3 | import time
4 | import sqlalchemy as sa
5 | from flask import render_template
6 | from rq import get_current_job
7 | from app import create_app, db
8 | from app.models import User, Post, Task
9 | from app.email import send_email
10 |
11 | app = create_app()
12 | app.app_context().push()
13 |
14 |
15 | def _set_task_progress(progress):
16 | job = get_current_job()
17 | if job:
18 | job.meta['progress'] = progress
19 | job.save_meta()
20 | task = db.session.get(Task, job.get_id())
21 | task.user.add_notification('task_progress', {'task_id': job.get_id(),
22 | 'progress': progress})
23 | if progress >= 100:
24 | task.complete = True
25 | db.session.commit()
26 |
27 |
28 | def export_posts(user_id):
29 | try:
30 | user = db.session.get(User, user_id)
31 | _set_task_progress(0)
32 | data = []
33 | i = 0
34 | total_posts = db.session.scalar(sa.select(sa.func.count()).select_from(
35 | user.posts.select().subquery()))
36 | for post in db.session.scalars(user.posts.select().order_by(
37 | Post.timestamp.asc())):
38 | data.append({'body': post.body,
39 | 'timestamp': post.timestamp.isoformat() + 'Z'})
40 | time.sleep(5)
41 | i += 1
42 | _set_task_progress(100 * i // total_posts)
43 |
44 | send_email(
45 | '[Microblog] Your blog posts',
46 | sender=app.config['ADMINS'][0], recipients=[user.email],
47 | text_body=render_template('email/export_posts.txt', user=user),
48 | html_body=render_template('email/export_posts.html', user=user),
49 | attachments=[('posts.json', 'application/json',
50 | json.dumps({'posts': data}, indent=4))],
51 | sync=True)
52 | except Exception:
53 | _set_task_progress(100)
54 | app.logger.error('Unhandled exception', exc_info=sys.exc_info())
55 | finally:
56 | _set_task_progress(100)
57 |
--------------------------------------------------------------------------------
/app/auth/forms.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from flask_babel import _, lazy_gettext as _l
3 | from wtforms import StringField, PasswordField, BooleanField, SubmitField
4 | from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
5 | import sqlalchemy as sa
6 | from app import db
7 | from app.models import User
8 |
9 |
10 | class LoginForm(FlaskForm):
11 | username = StringField(_l('Username'), validators=[DataRequired()])
12 | password = PasswordField(_l('Password'), validators=[DataRequired()])
13 | remember_me = BooleanField(_l('Remember Me'))
14 | submit = SubmitField(_l('Sign In'))
15 |
16 |
17 | class RegistrationForm(FlaskForm):
18 | username = StringField(_l('Username'), validators=[DataRequired()])
19 | email = StringField(_l('Email'), validators=[DataRequired(), Email()])
20 | password = PasswordField(_l('Password'), validators=[DataRequired()])
21 | password2 = PasswordField(
22 | _l('Repeat Password'), validators=[DataRequired(),
23 | EqualTo('password')])
24 | submit = SubmitField(_l('Register'))
25 |
26 | def validate_username(self, username):
27 | user = db.session.scalar(sa.select(User).where(
28 | User.username == username.data))
29 | if user is not None:
30 | raise ValidationError(_('Please use a different username.'))
31 |
32 | def validate_email(self, email):
33 | user = db.session.scalar(sa.select(User).where(
34 | User.email == email.data))
35 | if user is not None:
36 | raise ValidationError(_('Please use a different email address.'))
37 |
38 |
39 | class ResetPasswordRequestForm(FlaskForm):
40 | email = StringField(_l('Email'), validators=[DataRequired(), Email()])
41 | submit = SubmitField(_l('Request Password Reset'))
42 |
43 |
44 | class ResetPasswordForm(FlaskForm):
45 | password = PasswordField(_l('Password'), validators=[DataRequired()])
46 | password2 = PasswordField(
47 | _l('Repeat Password'), validators=[DataRequired(),
48 | EqualTo('password')])
49 | submit = SubmitField(_l('Request Password Reset'))
50 |
--------------------------------------------------------------------------------
/migrations/versions/d049de007ccf_private_messages.py:
--------------------------------------------------------------------------------
1 | """private messages
2 |
3 | Revision ID: d049de007ccf
4 | Revises: 834b1a697901
5 | Create Date: 2017-11-12 23:30:28.571784
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'd049de007ccf'
14 | down_revision = '2b017edaa91f'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('message',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('sender_id', sa.Integer(), nullable=False),
24 | sa.Column('recipient_id', sa.Integer(), nullable=False),
25 | sa.Column('body', sa.String(length=140), nullable=False),
26 | sa.Column('timestamp', sa.DateTime(), nullable=False),
27 | sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ),
28 | sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ),
29 | sa.PrimaryKeyConstraint('id')
30 | )
31 |
32 | with op.batch_alter_table('message', schema=None) as batch_op:
33 | batch_op.create_index(batch_op.f('ix_message_recipient_id'), ['recipient_id'], unique=False)
34 | batch_op.create_index(batch_op.f('ix_message_sender_id'), ['sender_id'], unique=False)
35 | batch_op.create_index(batch_op.f('ix_message_timestamp'), ['timestamp'], unique=False)
36 |
37 | with op.batch_alter_table('user', schema=None) as batch_op:
38 | batch_op.add_column(sa.Column('last_message_read_time', sa.DateTime(), nullable=True))
39 | # ### end Alembic commands ###
40 |
41 |
42 | def downgrade():
43 | # ### commands auto generated by Alembic - please adjust! ###
44 | with op.batch_alter_table('user', schema=None) as batch_op:
45 | batch_op.drop_column('last_message_read_time')
46 |
47 | with op.batch_alter_table('message', schema=None) as batch_op:
48 | batch_op.drop_index(batch_op.f('ix_message_timestamp'))
49 | batch_op.drop_index(batch_op.f('ix_message_sender_id'))
50 | batch_op.drop_index(batch_op.f('ix_message_recipient_id'))
51 |
52 | op.drop_table('message')
53 | # ### end Alembic commands ###
54 |
--------------------------------------------------------------------------------
/app/templates/bootstrap_wtf.html:
--------------------------------------------------------------------------------
1 | {% macro form_field(field, autofocus) %}
2 | {%- if field.type == 'BooleanField' %}
3 |
4 | {{ field(class='form-check-input') }}
5 | {{ field.label(class='form-check-label') }}
6 |
7 | {%- elif field.type == 'RadioField' %}
8 | {{ field.label(class='form-label') }}
9 | {%- for item in field %}
10 |
11 | {{ item(class='form-check-input') }}
12 | {{ item.label(class='form-check-label') }}
13 |
14 | {%- endfor %}
15 | {%- elif field.type == 'SelectField' %}
16 | {{ field.label(class='form-label') }}
17 | {{ field(class='form-select mb-3') }}
18 | {%- elif field.type == 'TextAreaField' %}
19 |
20 | {{ field.label(class='form-label') }}
21 | {% if autofocus %}
22 | {{ field(class='form-control' + (' is-invalid' if field.errors else ''), autofocus=True) }}
23 | {% else %}
24 | {{ field(class='form-control' + (' is-invalid' if field.errors else '')) }}
25 | {% endif %}
26 | {%- for error in field.errors %}
27 |
{{ error }}
28 | {%- endfor %}
29 |
30 | {%- elif field.type == 'SubmitField' %}
31 | {{ field(class='btn btn-primary mb-3') }}
32 | {%- else %}
33 |
34 | {{ field.label(class='form-label') }}
35 | {% if autofocus %}
36 | {{ field(class='form-control' + (' is-invalid' if field.errors else ''), autofocus=True) }}
37 | {% else %}
38 | {{ field(class='form-control' + (' is-invalid' if field.errors else '')) }}
39 | {% endif %}
40 | {%- for error in field.errors %}
41 |
{{ error }}
42 | {%- endfor %}
43 |
44 | {%- endif %}
45 | {% endmacro %}
46 |
47 | {% macro quick_form(form, action="", method="post", id="", novalidate=False) %}
48 |
70 | {% endmacro %}
71 |
--------------------------------------------------------------------------------
/app/templates/user.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 |
5 |
6 |  }}) |
7 |
8 | {{ _('User') }}: {{ user.username }}
9 | {% if user.about_me %}{{ user.about_me }} {% endif %}
10 | {% if user.last_seen %}
11 | {{ _('Last seen on') }}: {{ moment(user.last_seen).format('LLL') }}
12 | {% endif %}
13 | {{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_count()) }}
14 | {% if user == current_user %}
15 | {{ _('Edit your profile') }}
16 | {% if not current_user.get_task_in_progress('export_posts') %}
17 | {{ _('Export your posts') }}
18 | {% endif %}
19 | {% elif not current_user.is_following(user) %}
20 |
21 |
25 |
26 | {% else %}
27 |
28 |
32 |
33 | {% endif %}
34 | {% if user != current_user %}
35 | {{ _('Send private message') }}
36 | {% endif %}
37 | |
38 |
39 |
40 | {% for post in posts %}
41 | {% include '_post.html' %}
42 | {% endfor %}
43 |
57 | {% endblock %}
58 |
--------------------------------------------------------------------------------
/migrations/env.py:
--------------------------------------------------------------------------------
1 | from __future__ import with_statement
2 | from alembic import context
3 | from sqlalchemy import engine_from_config, pool
4 | from logging.config import fileConfig
5 | import logging
6 |
7 | # this is the Alembic Config object, which provides
8 | # access to the values within the .ini file in use.
9 | config = context.config
10 |
11 | # Interpret the config file for Python logging.
12 | # This line sets up loggers basically.
13 | fileConfig(config.config_file_name)
14 | logger = logging.getLogger('alembic.env')
15 |
16 | # add your model's MetaData object here
17 | # for 'autogenerate' support
18 | # from myapp import mymodel
19 | # target_metadata = mymodel.Base.metadata
20 | from flask import current_app
21 | config.set_main_option('sqlalchemy.url',
22 | current_app.config.get('SQLALCHEMY_DATABASE_URI'))
23 | target_metadata = current_app.extensions['migrate'].db.metadata
24 |
25 | # other values from the config, defined by the needs of env.py,
26 | # can be acquired:
27 | # my_important_option = config.get_main_option("my_important_option")
28 | # ... etc.
29 |
30 |
31 | def run_migrations_offline():
32 | """Run migrations in 'offline' mode.
33 |
34 | This configures the context with just a URL
35 | and not an Engine, though an Engine is acceptable
36 | here as well. By skipping the Engine creation
37 | we don't even need a DBAPI to be available.
38 |
39 | Calls to context.execute() here emit the given string to the
40 | script output.
41 |
42 | """
43 | url = config.get_main_option("sqlalchemy.url")
44 | context.configure(url=url)
45 |
46 | with context.begin_transaction():
47 | context.run_migrations()
48 |
49 |
50 | def run_migrations_online():
51 | """Run migrations in 'online' mode.
52 |
53 | In this scenario we need to create an Engine
54 | and associate a connection with the context.
55 |
56 | """
57 |
58 | # this callback is used to prevent an auto-migration from being generated
59 | # when there are no changes to the schema
60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
61 | def process_revision_directives(context, revision, directives):
62 | if getattr(config.cmd_opts, 'autogenerate', False):
63 | script = directives[0]
64 | if script.upgrade_ops.is_empty():
65 | directives[:] = []
66 | logger.info('No changes in schema detected.')
67 |
68 | engine = engine_from_config(config.get_section(config.config_ini_section),
69 | prefix='sqlalchemy.',
70 | poolclass=pool.NullPool)
71 |
72 | connection = engine.connect()
73 | context.configure(connection=connection,
74 | target_metadata=target_metadata,
75 | process_revision_directives=process_revision_directives,
76 | **current_app.extensions['migrate'].configure_args)
77 |
78 | try:
79 | with context.begin_transaction():
80 | context.run_migrations()
81 | finally:
82 | connection.close()
83 |
84 | if context.is_offline_mode():
85 | run_migrations_offline()
86 | else:
87 | run_migrations_online()
88 |
--------------------------------------------------------------------------------
/app/api/users.py:
--------------------------------------------------------------------------------
1 | import sqlalchemy as sa
2 | from flask import request, url_for, abort
3 | from app import db
4 | from app.models import User
5 | from app.api import bp
6 | from app.api.auth import token_auth
7 | from app.api.errors import bad_request
8 |
9 |
10 | @bp.route('/users/', methods=['GET'])
11 | @token_auth.login_required
12 | def get_user(id):
13 | return db.get_or_404(User, id).to_dict()
14 |
15 |
16 | @bp.route('/users', methods=['GET'])
17 | @token_auth.login_required
18 | def get_users():
19 | page = request.args.get('page', 1, type=int)
20 | per_page = min(request.args.get('per_page', 10, type=int), 100)
21 | return User.to_collection_dict(sa.select(User), page, per_page,
22 | 'api.get_users')
23 |
24 |
25 | @bp.route('/users//followers', methods=['GET'])
26 | @token_auth.login_required
27 | def get_followers(id):
28 | user = db.get_or_404(User, id)
29 | page = request.args.get('page', 1, type=int)
30 | per_page = min(request.args.get('per_page', 10, type=int), 100)
31 | return User.to_collection_dict(user.followers.select(), page, per_page,
32 | 'api.get_followers', id=id)
33 |
34 |
35 | @bp.route('/users//following', methods=['GET'])
36 | @token_auth.login_required
37 | def get_following(id):
38 | user = db.get_or_404(User, id)
39 | page = request.args.get('page', 1, type=int)
40 | per_page = min(request.args.get('per_page', 10, type=int), 100)
41 | return User.to_collection_dict(user.following.select(), page, per_page,
42 | 'api.get_following', id=id)
43 |
44 |
45 | @bp.route('/users', methods=['POST'])
46 | def create_user():
47 | data = request.get_json()
48 | if 'username' not in data or 'email' not in data or 'password' not in data:
49 | return bad_request('must include username, email and password fields')
50 | if db.session.scalar(sa.select(User).where(
51 | User.username == data['username'])):
52 | return bad_request('please use a different username')
53 | if db.session.scalar(sa.select(User).where(
54 | User.email == data['email'])):
55 | return bad_request('please use a different email address')
56 | user = User()
57 | user.from_dict(data, new_user=True)
58 | db.session.add(user)
59 | db.session.commit()
60 | return user.to_dict(), 201, {'Location': url_for('api.get_user',
61 | id=user.id)}
62 |
63 |
64 | @bp.route('/users/', methods=['PUT'])
65 | @token_auth.login_required
66 | def update_user(id):
67 | if token_auth.current_user().id != id:
68 | abort(403)
69 | user = db.get_or_404(User, id)
70 | data = request.get_json()
71 | if 'username' in data and data['username'] != user.username and \
72 | db.session.scalar(sa.select(User).where(
73 | User.username == data['username'])):
74 | return bad_request('please use a different username')
75 | if 'email' in data and data['email'] != user.email and \
76 | db.session.scalar(sa.select(User).where(
77 | User.email == data['email'])):
78 | return bad_request('please use a different email address')
79 | user.from_dict(data, new_user=False)
80 | db.session.commit()
81 | return user.to_dict()
82 |
--------------------------------------------------------------------------------
/app/auth/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, redirect, url_for, flash, request
2 | from urllib.parse import urlsplit
3 | from flask_login import login_user, logout_user, current_user
4 | from flask_babel import _
5 | import sqlalchemy as sa
6 | from app import db
7 | from app.auth import bp
8 | from app.auth.forms import LoginForm, RegistrationForm, \
9 | ResetPasswordRequestForm, ResetPasswordForm
10 | from app.models import User
11 | from app.auth.email import send_password_reset_email
12 |
13 |
14 | @bp.route('/login', methods=['GET', 'POST'])
15 | def login():
16 | if current_user.is_authenticated:
17 | return redirect(url_for('main.index'))
18 | form = LoginForm()
19 | if form.validate_on_submit():
20 | user = db.session.scalar(
21 | sa.select(User).where(User.username == form.username.data))
22 | if user is None or not user.check_password(form.password.data):
23 | flash(_('Invalid username or password'))
24 | return redirect(url_for('auth.login'))
25 | login_user(user, remember=form.remember_me.data)
26 | next_page = request.args.get('next')
27 | if not next_page or urlsplit(next_page).netloc != '':
28 | next_page = url_for('main.index')
29 | return redirect(next_page)
30 | return render_template('auth/login.html', title=_('Sign In'), form=form)
31 |
32 |
33 | @bp.route('/logout')
34 | def logout():
35 | logout_user()
36 | return redirect(url_for('main.index'))
37 |
38 |
39 | @bp.route('/register', methods=['GET', 'POST'])
40 | def register():
41 | if current_user.is_authenticated:
42 | return redirect(url_for('main.index'))
43 | form = RegistrationForm()
44 | if form.validate_on_submit():
45 | user = User(username=form.username.data, email=form.email.data)
46 | user.set_password(form.password.data)
47 | db.session.add(user)
48 | db.session.commit()
49 | flash(_('Congratulations, you are now a registered user!'))
50 | return redirect(url_for('auth.login'))
51 | return render_template('auth/register.html', title=_('Register'),
52 | form=form)
53 |
54 |
55 | @bp.route('/reset_password_request', methods=['GET', 'POST'])
56 | def reset_password_request():
57 | if current_user.is_authenticated:
58 | return redirect(url_for('main.index'))
59 | form = ResetPasswordRequestForm()
60 | if form.validate_on_submit():
61 | user = db.session.scalar(
62 | sa.select(User).where(User.email == form.email.data))
63 | if user:
64 | send_password_reset_email(user)
65 | flash(
66 | _('Check your email for the instructions to reset your password'))
67 | return redirect(url_for('auth.login'))
68 | return render_template('auth/reset_password_request.html',
69 | title=_('Reset Password'), form=form)
70 |
71 |
72 | @bp.route('/reset_password/', methods=['GET', 'POST'])
73 | def reset_password(token):
74 | if current_user.is_authenticated:
75 | return redirect(url_for('main.index'))
76 | user = User.verify_reset_password_token(token)
77 | if not user:
78 | return redirect(url_for('main.index'))
79 | form = ResetPasswordForm()
80 | if form.validate_on_submit():
81 | user.set_password(form.password.data)
82 | db.session.commit()
83 | flash(_('Your password has been reset.'))
84 | return redirect(url_for('auth.login'))
85 | return render_template('auth/reset_password.html', form=form)
86 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from logging.handlers import SMTPHandler, RotatingFileHandler
3 | import os
4 | from flask import Flask, request, current_app
5 | from flask_sqlalchemy import SQLAlchemy
6 | from flask_migrate import Migrate
7 | from flask_login import LoginManager
8 | from flask_mail import Mail
9 | from flask_moment import Moment
10 | from flask_babel import Babel, lazy_gettext as _l
11 | from elasticsearch import Elasticsearch
12 | from redis import Redis
13 | import rq
14 | from config import Config
15 |
16 |
17 | def get_locale():
18 | return request.accept_languages.best_match(current_app.config['LANGUAGES'])
19 |
20 |
21 | db = SQLAlchemy()
22 | migrate = Migrate()
23 | login = LoginManager()
24 | login.login_view = 'auth.login'
25 | login.login_message = _l('Please log in to access this page.')
26 | mail = Mail()
27 | moment = Moment()
28 | babel = Babel()
29 |
30 |
31 | def create_app(config_class=Config):
32 | app = Flask(__name__)
33 | app.config.from_object(config_class)
34 |
35 | db.init_app(app)
36 | migrate.init_app(app, db)
37 | login.init_app(app)
38 | mail.init_app(app)
39 | moment.init_app(app)
40 | babel.init_app(app, locale_selector=get_locale)
41 | app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
42 | if app.config['ELASTICSEARCH_URL'] else None
43 | app.redis = Redis.from_url(app.config['REDIS_URL'])
44 | app.task_queue = rq.Queue('microblog-tasks', connection=app.redis)
45 |
46 | from app.errors import bp as errors_bp
47 | app.register_blueprint(errors_bp)
48 |
49 | from app.auth import bp as auth_bp
50 | app.register_blueprint(auth_bp, url_prefix='/auth')
51 |
52 | from app.main import bp as main_bp
53 | app.register_blueprint(main_bp)
54 |
55 | from app.cli import bp as cli_bp
56 | app.register_blueprint(cli_bp)
57 |
58 | from app.api import bp as api_bp
59 | app.register_blueprint(api_bp, url_prefix='/api')
60 |
61 | if not app.debug and not app.testing:
62 | if app.config['MAIL_SERVER']:
63 | auth = None
64 | if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']:
65 | auth = (app.config['MAIL_USERNAME'],
66 | app.config['MAIL_PASSWORD'])
67 | secure = None
68 | if app.config['MAIL_USE_TLS']:
69 | secure = ()
70 | mail_handler = SMTPHandler(
71 | mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
72 | fromaddr='no-reply@' + app.config['MAIL_SERVER'],
73 | toaddrs=app.config['ADMINS'], subject='Microblog Failure',
74 | credentials=auth, secure=secure)
75 | mail_handler.setLevel(logging.ERROR)
76 | app.logger.addHandler(mail_handler)
77 |
78 | if app.config['LOG_TO_STDOUT']:
79 | stream_handler = logging.StreamHandler()
80 | stream_handler.setLevel(logging.INFO)
81 | app.logger.addHandler(stream_handler)
82 | else:
83 | if not os.path.exists('logs'):
84 | os.mkdir('logs')
85 | file_handler = RotatingFileHandler('logs/microblog.log',
86 | maxBytes=10240, backupCount=10)
87 | file_handler.setFormatter(logging.Formatter(
88 | '%(asctime)s %(levelname)s: %(message)s '
89 | '[in %(pathname)s:%(lineno)d]'))
90 | file_handler.setLevel(logging.INFO)
91 | app.logger.addHandler(file_handler)
92 |
93 | app.logger.setLevel(logging.INFO)
94 | app.logger.info('Microblog startup')
95 |
96 | return app
97 |
98 |
99 | from app import models
100 |
--------------------------------------------------------------------------------
/tests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from datetime import datetime, timezone, timedelta
3 | import unittest
4 | from app import create_app, db
5 | from app.models import User, Post
6 | from config import Config
7 |
8 |
9 | class TestConfig(Config):
10 | TESTING = True
11 | SQLALCHEMY_DATABASE_URI = 'sqlite://'
12 | ELASTICSEARCH_URL = None
13 |
14 |
15 | class UserModelCase(unittest.TestCase):
16 | def setUp(self):
17 | self.app = create_app(TestConfig)
18 | self.app_context = self.app.app_context()
19 | self.app_context.push()
20 | db.create_all()
21 |
22 | def tearDown(self):
23 | db.session.remove()
24 | db.drop_all()
25 | self.app_context.pop()
26 |
27 | def test_password_hashing(self):
28 | u = User(username='susan', email='susan@example.com')
29 | u.set_password('cat')
30 | self.assertFalse(u.check_password('dog'))
31 | self.assertTrue(u.check_password('cat'))
32 |
33 | def test_avatar(self):
34 | u = User(username='john', email='john@example.com')
35 | self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/'
36 | 'd4c74594d841139328695756648b6bd6'
37 | '?d=identicon&s=128'))
38 |
39 | def test_follow(self):
40 | u1 = User(username='john', email='john@example.com')
41 | u2 = User(username='susan', email='susan@example.com')
42 | db.session.add(u1)
43 | db.session.add(u2)
44 | db.session.commit()
45 | following = db.session.scalars(u1.following.select()).all()
46 | followers = db.session.scalars(u2.followers.select()).all()
47 | self.assertEqual(following, [])
48 | self.assertEqual(followers, [])
49 |
50 | u1.follow(u2)
51 | db.session.commit()
52 | self.assertTrue(u1.is_following(u2))
53 | self.assertEqual(u1.following_count(), 1)
54 | self.assertEqual(u2.followers_count(), 1)
55 | u1_following = db.session.scalars(u1.following.select()).all()
56 | u2_followers = db.session.scalars(u2.followers.select()).all()
57 | self.assertEqual(u1_following[0].username, 'susan')
58 | self.assertEqual(u2_followers[0].username, 'john')
59 |
60 | u1.unfollow(u2)
61 | db.session.commit()
62 | self.assertFalse(u1.is_following(u2))
63 | self.assertEqual(u1.following_count(), 0)
64 | self.assertEqual(u2.followers_count(), 0)
65 |
66 | def test_follow_posts(self):
67 | # create four users
68 | u1 = User(username='john', email='john@example.com')
69 | u2 = User(username='susan', email='susan@example.com')
70 | u3 = User(username='mary', email='mary@example.com')
71 | u4 = User(username='david', email='david@example.com')
72 | db.session.add_all([u1, u2, u3, u4])
73 |
74 | # create four posts
75 | now = datetime.now(timezone.utc)
76 | p1 = Post(body="post from john", author=u1,
77 | timestamp=now + timedelta(seconds=1))
78 | p2 = Post(body="post from susan", author=u2,
79 | timestamp=now + timedelta(seconds=4))
80 | p3 = Post(body="post from mary", author=u3,
81 | timestamp=now + timedelta(seconds=3))
82 | p4 = Post(body="post from david", author=u4,
83 | timestamp=now + timedelta(seconds=2))
84 | db.session.add_all([p1, p2, p3, p4])
85 | db.session.commit()
86 |
87 | # setup the followers
88 | u1.follow(u2) # john follows susan
89 | u1.follow(u4) # john follows david
90 | u2.follow(u3) # susan follows mary
91 | u3.follow(u4) # mary follows david
92 | db.session.commit()
93 |
94 | # check the following posts of each user
95 | f1 = db.session.scalars(u1.following_posts()).all()
96 | f2 = db.session.scalars(u2.following_posts()).all()
97 | f3 = db.session.scalars(u3.following_posts()).all()
98 | f4 = db.session.scalars(u4.following_posts()).all()
99 | self.assertEqual(f1, [p2, p4, p1])
100 | self.assertEqual(f2, [p2, p3])
101 | self.assertEqual(f3, [p3, p4])
102 | self.assertEqual(f4, [p4])
103 |
104 |
105 | if __name__ == '__main__':
106 | unittest.main(verbosity=2)
107 |
--------------------------------------------------------------------------------
/app/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {% if title %}
7 | {{ title }} - Microblog
8 | {% else %}
9 | {{ _('Welcome to Microblog') }}
10 | {% endif %}
11 |
16 |
17 |
18 |
67 |
68 | {% if current_user.is_authenticated %}
69 | {% with tasks = current_user.get_tasks_in_progress() %}
70 | {% if tasks %}
71 | {% for task in tasks %}
72 |
73 | {{ task.description }}
74 | {{ task.get_progress() }}%
75 |
76 | {% endfor %}
77 | {% endif %}
78 | {% endwith %}
79 | {% endif %}
80 |
81 | {% with messages = get_flashed_messages() %}
82 | {% if messages %}
83 | {% for message in messages %}
84 |
{{ message }}
85 | {% endfor %}
86 | {% endif %}
87 | {% endwith %}
88 | {% block content %}{% endblock %}
89 |
90 |
95 | {{ moment.include_moment() }}
96 | {{ moment.lang(g.locale) }}
97 |
180 |
181 |
182 |
--------------------------------------------------------------------------------
/app/translations/es/LC_MESSAGES/messages.po:
--------------------------------------------------------------------------------
1 | # Spanish translations for PROJECT.
2 | # Copyright (C) 2017 ORGANIZATION
3 | # This file is distributed under the same license as the PROJECT project.
4 | # FIRST AUTHOR , 2017.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PROJECT VERSION\n"
9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10 | "POT-Creation-Date: 2017-11-25 18:27-0800\n"
11 | "PO-Revision-Date: 2017-09-29 23:25-0700\n"
12 | "Last-Translator: FULL NAME \n"
13 | "Language: es\n"
14 | "Language-Team: es \n"
15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=utf-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Generated-By: Babel 2.5.1\n"
20 |
21 | #: app/__init__.py:20
22 | msgid "Please log in to access this page."
23 | msgstr "Por favor ingrese para acceder a esta página."
24 |
25 | #: app/translate.py:10
26 | msgid "Error: the translation service is not configured."
27 | msgstr "Error: el servicio de traducciones no está configurado."
28 |
29 | #: app/translate.py:18
30 | msgid "Error: the translation service failed."
31 | msgstr "Error el servicio de traducciones ha fallado."
32 |
33 | #: app/auth/email.py:8
34 | msgid "[Microblog] Reset Your Password"
35 | msgstr "[Microblog] Nueva Contraseña"
36 |
37 | #: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10
38 | msgid "Username"
39 | msgstr "Nombre de usuario"
40 |
41 | #: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42
42 | msgid "Password"
43 | msgstr "Contraseña"
44 |
45 | #: app/auth/forms.py:12
46 | msgid "Remember Me"
47 | msgstr "Recordarme"
48 |
49 | #: app/auth/forms.py:13 app/templates/auth/login.html:5
50 | msgid "Sign In"
51 | msgstr "Ingresar"
52 |
53 | #: app/auth/forms.py:18 app/auth/forms.py:37
54 | msgid "Email"
55 | msgstr "Email"
56 |
57 | #: app/auth/forms.py:21 app/auth/forms.py:44
58 | msgid "Repeat Password"
59 | msgstr "Repetir Contraseña"
60 |
61 | #: app/auth/forms.py:23 app/templates/auth/register.html:5
62 | msgid "Register"
63 | msgstr "Registrarse"
64 |
65 | #: app/auth/forms.py:28 app/main/forms.py:23
66 | msgid "Please use a different username."
67 | msgstr "Por favor use un nombre de usuario diferente."
68 |
69 | #: app/auth/forms.py:33
70 | msgid "Please use a different email address."
71 | msgstr "Por favor use una dirección de email diferente."
72 |
73 | #: app/auth/forms.py:38 app/auth/forms.py:46
74 | msgid "Request Password Reset"
75 | msgstr "Pedir una nueva contraseña"
76 |
77 | #: app/auth/routes.py:20
78 | msgid "Invalid username or password"
79 | msgstr "Nombre de usuario o contraseña inválidos"
80 |
81 | #: app/auth/routes.py:46
82 | msgid "Congratulations, you are now a registered user!"
83 | msgstr "¡Felicitaciones, ya eres un usuario registrado!"
84 |
85 | #: app/auth/routes.py:61
86 | msgid "Check your email for the instructions to reset your password"
87 | msgstr "Busca en tu email las instrucciones para crear una nueva contraseña"
88 |
89 | #: app/auth/routes.py:78
90 | msgid "Your password has been reset."
91 | msgstr "Tu contraseña ha sido cambiada."
92 |
93 | #: app/main/forms.py:11
94 | msgid "About me"
95 | msgstr "Acerca de mí"
96 |
97 | #: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44
98 | msgid "Submit"
99 | msgstr "Enviar"
100 |
101 | #: app/main/forms.py:27
102 | msgid "Say something"
103 | msgstr "Dí algo"
104 |
105 | #: app/main/forms.py:32
106 | msgid "Search"
107 | msgstr "Buscar"
108 |
109 | #: app/main/forms.py:43
110 | msgid "Message"
111 | msgstr "Mensaje"
112 |
113 | #: app/main/routes.py:36
114 | msgid "Your post is now live!"
115 | msgstr "¡Tu artículo ha sido publicado!"
116 |
117 | #: app/main/routes.py:94
118 | msgid "Your changes have been saved."
119 | msgstr "Tus cambios han sido salvados."
120 |
121 | #: app/main/routes.py:99 app/templates/edit_profile.html:5
122 | msgid "Edit Profile"
123 | msgstr "Editar Perfil"
124 |
125 | #: app/main/routes.py:108 app/main/routes.py:124
126 | #, python-format
127 | msgid "User %(username)s not found."
128 | msgstr "El usuario %(username)s no ha sido encontrado."
129 |
130 | #: app/main/routes.py:111
131 | msgid "You cannot follow yourself!"
132 | msgstr "¡No te puedes seguir a tí mismo!"
133 |
134 | #: app/main/routes.py:115
135 | #, python-format
136 | msgid "You are following %(username)s!"
137 | msgstr "¡Ahora estás siguiendo a %(username)s!"
138 |
139 | #: app/main/routes.py:127
140 | msgid "You cannot unfollow yourself!"
141 | msgstr "¡No te puedes dejar de seguir a tí mismo!"
142 |
143 | #: app/main/routes.py:131
144 | #, python-format
145 | msgid "You are not following %(username)s."
146 | msgstr "No estás siguiendo a %(username)s."
147 |
148 | #: app/main/routes.py:170
149 | msgid "Your message has been sent."
150 | msgstr "Tu mensaje ha sido enviado."
151 |
152 | #: app/main/routes.py:172
153 | msgid "Send Message"
154 | msgstr "Enviar Mensaje"
155 |
156 | #: app/main/routes.py:197
157 | msgid "An export task is currently in progress"
158 | msgstr "Una tarea de exportación esta en progreso"
159 |
160 | #: app/main/routes.py:199
161 | msgid "Exporting posts..."
162 | msgstr "Exportando artículos..."
163 |
164 | #: app/templates/_post.html:16
165 | #, python-format
166 | msgid "%(username)s said %(when)s"
167 | msgstr "%(username)s dijo %(when)s"
168 |
169 | #: app/templates/_post.html:27
170 | msgid "Translate"
171 | msgstr "Traducir"
172 |
173 | #: app/templates/base.html:4
174 | msgid "Welcome to Microblog"
175 | msgstr "Bienvenido a Microblog"
176 |
177 | #: app/templates/base.html:21
178 | msgid "Home"
179 | msgstr "Inicio"
180 |
181 | #: app/templates/base.html:22
182 | msgid "Explore"
183 | msgstr "Explorar"
184 |
185 | #: app/templates/base.html:33
186 | msgid "Login"
187 | msgstr "Ingresar"
188 |
189 | #: app/templates/base.html:36 app/templates/messages.html:4
190 | msgid "Messages"
191 | msgstr "Mensajes"
192 |
193 | #: app/templates/base.html:45
194 | msgid "Profile"
195 | msgstr "Perfil"
196 |
197 | #: app/templates/base.html:46
198 | msgid "Logout"
199 | msgstr "Salir"
200 |
201 | #: app/templates/base.html:95
202 | msgid "Error: Could not contact server."
203 | msgstr "Error: el servidor no pudo ser contactado."
204 |
205 | #: app/templates/index.html:5
206 | #, python-format
207 | msgid "Hi, %(username)s!"
208 | msgstr "¡Hola, %(username)s!"
209 |
210 | #: app/templates/index.html:17 app/templates/user.html:37
211 | msgid "Newer posts"
212 | msgstr "Artículos siguientes"
213 |
214 | #: app/templates/index.html:22 app/templates/user.html:42
215 | msgid "Older posts"
216 | msgstr "Artículos previos"
217 |
218 | #: app/templates/messages.html:12
219 | msgid "Newer messages"
220 | msgstr "Mensajes siguientes"
221 |
222 | #: app/templates/messages.html:17
223 | msgid "Older messages"
224 | msgstr "Mensajes previos"
225 |
226 | #: app/templates/search.html:4
227 | msgid "Search Results"
228 | msgstr ""
229 |
230 | #: app/templates/search.html:12
231 | msgid "Previous results"
232 | msgstr ""
233 |
234 | #: app/templates/search.html:17
235 | msgid "Next results"
236 | msgstr ""
237 |
238 | #: app/templates/send_message.html:5
239 | #, python-format
240 | msgid "Send Message to %(recipient)s"
241 | msgstr "Enviar Mensaje a %(recipient)s"
242 |
243 | #: app/templates/user.html:8
244 | msgid "User"
245 | msgstr "Usuario"
246 |
247 | #: app/templates/user.html:11 app/templates/user_popup.html:9
248 | msgid "Last seen on"
249 | msgstr "Última visita"
250 |
251 | #: app/templates/user.html:13 app/templates/user_popup.html:11
252 | #, python-format
253 | msgid "%(count)d followers"
254 | msgstr "%(count)d seguidores"
255 |
256 | #: app/templates/user.html:13 app/templates/user_popup.html:11
257 | #, python-format
258 | msgid "%(count)d following"
259 | msgstr "siguiendo a %(count)d"
260 |
261 | #: app/templates/user.html:15
262 | msgid "Edit your profile"
263 | msgstr "Editar tu perfil"
264 |
265 | #: app/templates/user.html:17
266 | msgid "Export your posts"
267 | msgstr "Exportar tus artículos"
268 |
269 | #: app/templates/user.html:20 app/templates/user_popup.html:14
270 | msgid "Follow"
271 | msgstr "Seguir"
272 |
273 | #: app/templates/user.html:22 app/templates/user_popup.html:16
274 | msgid "Unfollow"
275 | msgstr "Dejar de seguir"
276 |
277 | #: app/templates/user.html:25
278 | msgid "Send private message"
279 | msgstr "Enviar mensaje privado"
280 |
281 | #: app/templates/auth/login.html:12
282 | msgid "New User?"
283 | msgstr "¿Usuario Nuevo?"
284 |
285 | #: app/templates/auth/login.html:12
286 | msgid "Click to Register!"
287 | msgstr "¡Haz click aquí para registrarte!"
288 |
289 | #: app/templates/auth/login.html:14
290 | msgid "Forgot Your Password?"
291 | msgstr "¿Te olvidaste tu contraseña?"
292 |
293 | #: app/templates/auth/login.html:15
294 | msgid "Click to Reset It"
295 | msgstr "Haz click aquí para pedir una nueva"
296 |
297 | #: app/templates/auth/reset_password.html:5
298 | msgid "Reset Your Password"
299 | msgstr "Nueva Contraseña"
300 |
301 | #: app/templates/auth/reset_password_request.html:5
302 | msgid "Reset Password"
303 | msgstr "Nueva Contraseña"
304 |
305 | #: app/templates/errors/404.html:4
306 | msgid "Not Found"
307 | msgstr "Página No Encontrada"
308 |
309 | #: app/templates/errors/404.html:5 app/templates/errors/500.html:6
310 | msgid "Back"
311 | msgstr "Atrás"
312 |
313 | #: app/templates/errors/500.html:4
314 | msgid "An unexpected error has occurred"
315 | msgstr "Ha ocurrido un error inesperado"
316 |
317 | #: app/templates/errors/500.html:5
318 | msgid "The administrator has been notified. Sorry for the inconvenience!"
319 | msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!"
320 |
321 |
--------------------------------------------------------------------------------
/app/main/routes.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timezone
2 | from flask import render_template, flash, redirect, url_for, request, g, \
3 | current_app
4 | from flask_login import current_user, login_required
5 | from flask_babel import _, get_locale
6 | import sqlalchemy as sa
7 | from langdetect import detect, LangDetectException
8 | from app import db
9 | from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm, \
10 | MessageForm
11 | from app.models import User, Post, Message, Notification
12 | from app.translate import translate
13 | from app.main import bp
14 |
15 |
16 | @bp.before_app_request
17 | def before_request():
18 | if current_user.is_authenticated:
19 | current_user.last_seen = datetime.now(timezone.utc)
20 | db.session.commit()
21 | g.search_form = SearchForm()
22 | g.locale = str(get_locale())
23 |
24 |
25 | @bp.route('/', methods=['GET', 'POST'])
26 | @bp.route('/index', methods=['GET', 'POST'])
27 | @login_required
28 | def index():
29 | form = PostForm()
30 | if form.validate_on_submit():
31 | try:
32 | language = detect(form.post.data)
33 | except LangDetectException:
34 | language = ''
35 | post = Post(body=form.post.data, author=current_user,
36 | language=language)
37 | db.session.add(post)
38 | db.session.commit()
39 | flash(_('Your post is now live!'))
40 | return redirect(url_for('main.index'))
41 | page = request.args.get('page', 1, type=int)
42 | posts = db.paginate(current_user.following_posts(), page=page,
43 | per_page=current_app.config['POSTS_PER_PAGE'],
44 | error_out=False)
45 | next_url = url_for('main.index', page=posts.next_num) \
46 | if posts.has_next else None
47 | prev_url = url_for('main.index', page=posts.prev_num) \
48 | if posts.has_prev else None
49 | return render_template('index.html', title=_('Home'), form=form,
50 | posts=posts.items, next_url=next_url,
51 | prev_url=prev_url)
52 |
53 |
54 | @bp.route('/explore')
55 | @login_required
56 | def explore():
57 | page = request.args.get('page', 1, type=int)
58 | query = sa.select(Post).order_by(Post.timestamp.desc())
59 | posts = db.paginate(query, page=page,
60 | per_page=current_app.config['POSTS_PER_PAGE'],
61 | error_out=False)
62 | next_url = url_for('main.explore', page=posts.next_num) \
63 | if posts.has_next else None
64 | prev_url = url_for('main.explore', page=posts.prev_num) \
65 | if posts.has_prev else None
66 | return render_template('index.html', title=_('Explore'),
67 | posts=posts.items, next_url=next_url,
68 | prev_url=prev_url)
69 |
70 |
71 | @bp.route('/user/')
72 | @login_required
73 | def user(username):
74 | user = db.first_or_404(sa.select(User).where(User.username == username))
75 | page = request.args.get('page', 1, type=int)
76 | query = user.posts.select().order_by(Post.timestamp.desc())
77 | posts = db.paginate(query, page=page,
78 | per_page=current_app.config['POSTS_PER_PAGE'],
79 | error_out=False)
80 | next_url = url_for('main.user', username=user.username,
81 | page=posts.next_num) if posts.has_next else None
82 | prev_url = url_for('main.user', username=user.username,
83 | page=posts.prev_num) if posts.has_prev else None
84 | form = EmptyForm()
85 | return render_template('user.html', user=user, posts=posts.items,
86 | next_url=next_url, prev_url=prev_url, form=form)
87 |
88 |
89 | @bp.route('/user//popup')
90 | @login_required
91 | def user_popup(username):
92 | user = db.first_or_404(sa.select(User).where(User.username == username))
93 | form = EmptyForm()
94 | return render_template('user_popup.html', user=user, form=form)
95 |
96 |
97 | @bp.route('/edit_profile', methods=['GET', 'POST'])
98 | @login_required
99 | def edit_profile():
100 | form = EditProfileForm(current_user.username)
101 | if form.validate_on_submit():
102 | current_user.username = form.username.data
103 | current_user.about_me = form.about_me.data
104 | db.session.commit()
105 | flash(_('Your changes have been saved.'))
106 | return redirect(url_for('main.edit_profile'))
107 | elif request.method == 'GET':
108 | form.username.data = current_user.username
109 | form.about_me.data = current_user.about_me
110 | return render_template('edit_profile.html', title=_('Edit Profile'),
111 | form=form)
112 |
113 |
114 | @bp.route('/follow/', methods=['POST'])
115 | @login_required
116 | def follow(username):
117 | form = EmptyForm()
118 | if form.validate_on_submit():
119 | user = db.session.scalar(
120 | sa.select(User).where(User.username == username))
121 | if user is None:
122 | flash(_('User %(username)s not found.', username=username))
123 | return redirect(url_for('main.index'))
124 | if user == current_user:
125 | flash(_('You cannot follow yourself!'))
126 | return redirect(url_for('main.user', username=username))
127 | current_user.follow(user)
128 | db.session.commit()
129 | flash(_('You are following %(username)s!', username=username))
130 | return redirect(url_for('main.user', username=username))
131 | else:
132 | return redirect(url_for('main.index'))
133 |
134 |
135 | @bp.route('/unfollow/', methods=['POST'])
136 | @login_required
137 | def unfollow(username):
138 | form = EmptyForm()
139 | if form.validate_on_submit():
140 | user = db.session.scalar(
141 | sa.select(User).where(User.username == username))
142 | if user is None:
143 | flash(_('User %(username)s not found.', username=username))
144 | return redirect(url_for('main.index'))
145 | if user == current_user:
146 | flash(_('You cannot unfollow yourself!'))
147 | return redirect(url_for('main.user', username=username))
148 | current_user.unfollow(user)
149 | db.session.commit()
150 | flash(_('You are not following %(username)s.', username=username))
151 | return redirect(url_for('main.user', username=username))
152 | else:
153 | return redirect(url_for('main.index'))
154 |
155 |
156 | @bp.route('/translate', methods=['POST'])
157 | @login_required
158 | def translate_text():
159 | data = request.get_json()
160 | return {'text': translate(data['text'],
161 | data['source_language'],
162 | data['dest_language'])}
163 |
164 |
165 | @bp.route('/search')
166 | @login_required
167 | def search():
168 | if not g.search_form.validate():
169 | return redirect(url_for('main.explore'))
170 | page = request.args.get('page', 1, type=int)
171 | posts, total = Post.search(g.search_form.q.data, page,
172 | current_app.config['POSTS_PER_PAGE'])
173 | next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \
174 | if total > page * current_app.config['POSTS_PER_PAGE'] else None
175 | prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \
176 | if page > 1 else None
177 | return render_template('search.html', title=_('Search'), posts=posts,
178 | next_url=next_url, prev_url=prev_url)
179 |
180 |
181 | @bp.route('/send_message/', methods=['GET', 'POST'])
182 | @login_required
183 | def send_message(recipient):
184 | user = db.first_or_404(sa.select(User).where(User.username == recipient))
185 | form = MessageForm()
186 | if form.validate_on_submit():
187 | msg = Message(author=current_user, recipient=user,
188 | body=form.message.data)
189 | db.session.add(msg)
190 | user.add_notification('unread_message_count',
191 | user.unread_message_count())
192 | db.session.commit()
193 | flash(_('Your message has been sent.'))
194 | return redirect(url_for('main.user', username=recipient))
195 | return render_template('send_message.html', title=_('Send Message'),
196 | form=form, recipient=recipient)
197 |
198 |
199 | @bp.route('/messages')
200 | @login_required
201 | def messages():
202 | current_user.last_message_read_time = datetime.now(timezone.utc)
203 | current_user.add_notification('unread_message_count', 0)
204 | db.session.commit()
205 | page = request.args.get('page', 1, type=int)
206 | query = current_user.messages_received.select().order_by(
207 | Message.timestamp.desc())
208 | messages = db.paginate(query, page=page,
209 | per_page=current_app.config['POSTS_PER_PAGE'],
210 | error_out=False)
211 | next_url = url_for('main.messages', page=messages.next_num) \
212 | if messages.has_next else None
213 | prev_url = url_for('main.messages', page=messages.prev_num) \
214 | if messages.has_prev else None
215 | return render_template('messages.html', messages=messages.items,
216 | next_url=next_url, prev_url=prev_url)
217 |
218 |
219 | @bp.route('/export_posts')
220 | @login_required
221 | def export_posts():
222 | if current_user.get_task_in_progress('export_posts'):
223 | flash(_('An export task is currently in progress'))
224 | else:
225 | current_user.launch_task('export_posts', _('Exporting posts...'))
226 | db.session.commit()
227 | return redirect(url_for('main.user', username=current_user.username))
228 |
229 |
230 | @bp.route('/notifications')
231 | @login_required
232 | def notifications():
233 | since = request.args.get('since', 0.0, type=float)
234 | query = current_user.notifications.select().where(
235 | Notification.timestamp > since).order_by(Notification.timestamp.asc())
236 | notifications = db.session.scalars(query)
237 | return [{
238 | 'name': n.name,
239 | 'data': n.get_data(),
240 | 'timestamp': n.timestamp
241 | } for n in notifications]
242 |
--------------------------------------------------------------------------------
/app/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timezone, timedelta
2 | from hashlib import md5
3 | import json
4 | import secrets
5 | from time import time
6 | from typing import Optional
7 | import sqlalchemy as sa
8 | import sqlalchemy.orm as so
9 | from flask import current_app, url_for
10 | from flask_login import UserMixin
11 | from werkzeug.security import generate_password_hash, check_password_hash
12 | import jwt
13 | import redis
14 | import rq
15 | from app import db, login
16 | from app.search import add_to_index, remove_from_index, query_index
17 |
18 |
19 | class SearchableMixin:
20 | @classmethod
21 | def search(cls, expression, page, per_page):
22 | ids, total = query_index(cls.__tablename__, expression, page, per_page)
23 | if total == 0:
24 | return [], 0
25 | when = []
26 | for i in range(len(ids)):
27 | when.append((ids[i], i))
28 | query = sa.select(cls).where(cls.id.in_(ids)).order_by(
29 | db.case(*when, value=cls.id))
30 | return db.session.scalars(query), total
31 |
32 | @classmethod
33 | def before_commit(cls, session):
34 | session._changes = {
35 | 'add': list(session.new),
36 | 'update': list(session.dirty),
37 | 'delete': list(session.deleted)
38 | }
39 |
40 | @classmethod
41 | def after_commit(cls, session):
42 | for obj in session._changes['add']:
43 | if isinstance(obj, SearchableMixin):
44 | add_to_index(obj.__tablename__, obj)
45 | for obj in session._changes['update']:
46 | if isinstance(obj, SearchableMixin):
47 | add_to_index(obj.__tablename__, obj)
48 | for obj in session._changes['delete']:
49 | if isinstance(obj, SearchableMixin):
50 | remove_from_index(obj.__tablename__, obj)
51 | session._changes = None
52 |
53 | @classmethod
54 | def reindex(cls):
55 | for obj in db.session.scalars(sa.select(cls)):
56 | add_to_index(cls.__tablename__, obj)
57 |
58 |
59 | db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit)
60 | db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit)
61 |
62 |
63 | class PaginatedAPIMixin(object):
64 | @staticmethod
65 | def to_collection_dict(query, page, per_page, endpoint, **kwargs):
66 | resources = db.paginate(query, page=page, per_page=per_page,
67 | error_out=False)
68 | data = {
69 | 'items': [item.to_dict() for item in resources.items],
70 | '_meta': {
71 | 'page': page,
72 | 'per_page': per_page,
73 | 'total_pages': resources.pages,
74 | 'total_items': resources.total
75 | },
76 | '_links': {
77 | 'self': url_for(endpoint, page=page, per_page=per_page,
78 | **kwargs),
79 | 'next': url_for(endpoint, page=page + 1, per_page=per_page,
80 | **kwargs) if resources.has_next else None,
81 | 'prev': url_for(endpoint, page=page - 1, per_page=per_page,
82 | **kwargs) if resources.has_prev else None
83 | }
84 | }
85 | return data
86 |
87 |
88 | followers = sa.Table(
89 | 'followers',
90 | db.metadata,
91 | sa.Column('follower_id', sa.Integer, sa.ForeignKey('user.id'),
92 | primary_key=True),
93 | sa.Column('followed_id', sa.Integer, sa.ForeignKey('user.id'),
94 | primary_key=True)
95 | )
96 |
97 |
98 | class User(PaginatedAPIMixin, UserMixin, db.Model):
99 | id: so.Mapped[int] = so.mapped_column(primary_key=True)
100 | username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True,
101 | unique=True)
102 | email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True,
103 | unique=True)
104 | password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256))
105 | about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
106 | last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(
107 | default=lambda: datetime.now(timezone.utc))
108 | last_message_read_time: so.Mapped[Optional[datetime]]
109 | token: so.Mapped[Optional[str]] = so.mapped_column(
110 | sa.String(32), index=True, unique=True)
111 | token_expiration: so.Mapped[Optional[datetime]]
112 |
113 | posts: so.WriteOnlyMapped['Post'] = so.relationship(
114 | back_populates='author')
115 | following: so.WriteOnlyMapped['User'] = so.relationship(
116 | secondary=followers, primaryjoin=(followers.c.follower_id == id),
117 | secondaryjoin=(followers.c.followed_id == id),
118 | back_populates='followers')
119 | followers: so.WriteOnlyMapped['User'] = so.relationship(
120 | secondary=followers, primaryjoin=(followers.c.followed_id == id),
121 | secondaryjoin=(followers.c.follower_id == id),
122 | back_populates='following')
123 | messages_sent: so.WriteOnlyMapped['Message'] = so.relationship(
124 | foreign_keys='Message.sender_id', back_populates='author')
125 | messages_received: so.WriteOnlyMapped['Message'] = so.relationship(
126 | foreign_keys='Message.recipient_id', back_populates='recipient')
127 | notifications: so.WriteOnlyMapped['Notification'] = so.relationship(
128 | back_populates='user')
129 | tasks: so.WriteOnlyMapped['Task'] = so.relationship(back_populates='user')
130 |
131 | def __repr__(self):
132 | return ''.format(self.username)
133 |
134 | def set_password(self, password):
135 | self.password_hash = generate_password_hash(password)
136 |
137 | def check_password(self, password):
138 | return check_password_hash(self.password_hash, password)
139 |
140 | def avatar(self, size):
141 | digest = md5(self.email.lower().encode('utf-8')).hexdigest()
142 | return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}'
143 |
144 | def follow(self, user):
145 | if not self.is_following(user):
146 | self.following.add(user)
147 |
148 | def unfollow(self, user):
149 | if self.is_following(user):
150 | self.following.remove(user)
151 |
152 | def is_following(self, user):
153 | query = self.following.select().where(User.id == user.id)
154 | return db.session.scalar(query) is not None
155 |
156 | def followers_count(self):
157 | query = sa.select(sa.func.count()).select_from(
158 | self.followers.select().subquery())
159 | return db.session.scalar(query)
160 |
161 | def following_count(self):
162 | query = sa.select(sa.func.count()).select_from(
163 | self.following.select().subquery())
164 | return db.session.scalar(query)
165 |
166 | def following_posts(self):
167 | Author = so.aliased(User)
168 | Follower = so.aliased(User)
169 | return (
170 | sa.select(Post)
171 | .join(Post.author.of_type(Author))
172 | .join(Author.followers.of_type(Follower), isouter=True)
173 | .where(sa.or_(
174 | Follower.id == self.id,
175 | Author.id == self.id,
176 | ))
177 | .group_by(Post)
178 | .order_by(Post.timestamp.desc())
179 | )
180 |
181 | def get_reset_password_token(self, expires_in=600):
182 | return jwt.encode(
183 | {'reset_password': self.id, 'exp': time() + expires_in},
184 | current_app.config['SECRET_KEY'], algorithm='HS256')
185 |
186 | @staticmethod
187 | def verify_reset_password_token(token):
188 | try:
189 | id = jwt.decode(token, current_app.config['SECRET_KEY'],
190 | algorithms=['HS256'])['reset_password']
191 | except Exception:
192 | return
193 | return db.session.get(User, id)
194 |
195 | def unread_message_count(self):
196 | last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
197 | query = sa.select(Message).where(Message.recipient == self,
198 | Message.timestamp > last_read_time)
199 | return db.session.scalar(sa.select(sa.func.count()).select_from(
200 | query.subquery()))
201 |
202 | def add_notification(self, name, data):
203 | db.session.execute(self.notifications.delete().where(
204 | Notification.name == name))
205 | n = Notification(name=name, payload_json=json.dumps(data), user=self)
206 | db.session.add(n)
207 | return n
208 |
209 | def launch_task(self, name, description, *args, **kwargs):
210 | rq_job = current_app.task_queue.enqueue(f'app.tasks.{name}', self.id,
211 | *args, **kwargs)
212 | task = Task(id=rq_job.get_id(), name=name, description=description,
213 | user=self)
214 | db.session.add(task)
215 | return task
216 |
217 | def get_tasks_in_progress(self):
218 | query = self.tasks.select().where(Task.complete == False)
219 | return db.session.scalars(query)
220 |
221 | def get_task_in_progress(self, name):
222 | query = self.tasks.select().where(Task.name == name,
223 | Task.complete == False)
224 | return db.session.scalar(query)
225 |
226 | def posts_count(self):
227 | query = sa.select(sa.func.count()).select_from(
228 | self.posts.select().subquery())
229 | return db.session.scalar(query)
230 |
231 | def to_dict(self, include_email=False):
232 | data = {
233 | 'id': self.id,
234 | 'username': self.username,
235 | 'last_seen': self.last_seen.replace(
236 | tzinfo=timezone.utc).isoformat(),
237 | 'about_me': self.about_me,
238 | 'post_count': self.posts_count(),
239 | 'follower_count': self.followers_count(),
240 | 'following_count': self.following_count(),
241 | '_links': {
242 | 'self': url_for('api.get_user', id=self.id),
243 | 'followers': url_for('api.get_followers', id=self.id),
244 | 'following': url_for('api.get_following', id=self.id),
245 | 'avatar': self.avatar(128)
246 | }
247 | }
248 | if include_email:
249 | data['email'] = self.email
250 | return data
251 |
252 | def from_dict(self, data, new_user=False):
253 | for field in ['username', 'email', 'about_me']:
254 | if field in data:
255 | setattr(self, field, data[field])
256 | if new_user and 'password' in data:
257 | self.set_password(data['password'])
258 |
259 | def get_token(self, expires_in=3600):
260 | now = datetime.now(timezone.utc)
261 | if self.token and self.token_expiration.replace(
262 | tzinfo=timezone.utc) > now + timedelta(seconds=60):
263 | return self.token
264 | self.token = secrets.token_hex(16)
265 | self.token_expiration = now + timedelta(seconds=expires_in)
266 | db.session.add(self)
267 | return self.token
268 |
269 | def revoke_token(self):
270 | self.token_expiration = datetime.now(timezone.utc) - timedelta(
271 | seconds=1)
272 |
273 | @staticmethod
274 | def check_token(token):
275 | user = db.session.scalar(sa.select(User).where(User.token == token))
276 | if user is None or user.token_expiration.replace(
277 | tzinfo=timezone.utc) < datetime.now(timezone.utc):
278 | return None
279 | return user
280 |
281 |
282 | @login.user_loader
283 | def load_user(id):
284 | return db.session.get(User, int(id))
285 |
286 |
287 | class Post(SearchableMixin, db.Model):
288 | __searchable__ = ['body']
289 | id: so.Mapped[int] = so.mapped_column(primary_key=True)
290 | body: so.Mapped[str] = so.mapped_column(sa.String(140))
291 | timestamp: so.Mapped[datetime] = so.mapped_column(
292 | index=True, default=lambda: datetime.now(timezone.utc))
293 | user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
294 | index=True)
295 | language: so.Mapped[Optional[str]] = so.mapped_column(sa.String(5))
296 |
297 | author: so.Mapped[User] = so.relationship(back_populates='posts')
298 |
299 | def __repr__(self):
300 | return ''.format(self.body)
301 |
302 |
303 | class Message(db.Model):
304 | id: so.Mapped[int] = so.mapped_column(primary_key=True)
305 | sender_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
306 | index=True)
307 | recipient_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
308 | index=True)
309 | body: so.Mapped[str] = so.mapped_column(sa.String(140))
310 | timestamp: so.Mapped[datetime] = so.mapped_column(
311 | index=True, default=lambda: datetime.now(timezone.utc))
312 |
313 | author: so.Mapped[User] = so.relationship(
314 | foreign_keys='Message.sender_id',
315 | back_populates='messages_sent')
316 | recipient: so.Mapped[User] = so.relationship(
317 | foreign_keys='Message.recipient_id',
318 | back_populates='messages_received')
319 |
320 | def __repr__(self):
321 | return ''.format(self.body)
322 |
323 |
324 | class Notification(db.Model):
325 | id: so.Mapped[int] = so.mapped_column(primary_key=True)
326 | name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True)
327 | user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id),
328 | index=True)
329 | timestamp: so.Mapped[float] = so.mapped_column(index=True, default=time)
330 | payload_json: so.Mapped[str] = so.mapped_column(sa.Text)
331 |
332 | user: so.Mapped[User] = so.relationship(back_populates='notifications')
333 |
334 | def get_data(self):
335 | return json.loads(str(self.payload_json))
336 |
337 |
338 | class Task(db.Model):
339 | id: so.Mapped[str] = so.mapped_column(sa.String(36), primary_key=True)
340 | name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True)
341 | description: so.Mapped[Optional[str]] = so.mapped_column(sa.String(128))
342 | user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id))
343 | complete: so.Mapped[bool] = so.mapped_column(default=False)
344 |
345 | user: so.Mapped[User] = so.relationship(back_populates='tasks')
346 |
347 | def get_rq_job(self):
348 | try:
349 | rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis)
350 | except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError):
351 | return None
352 | return rq_job
353 |
354 | def get_progress(self):
355 | job = self.get_rq_job()
356 | return job.meta.get('progress', 0) if job is not None else 100
357 |
--------------------------------------------------------------------------------