├── .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 |

14 | {{ form.hidden_tag() }} 15 | {{ form.submit(value=_('Follow'), class_='btn btn-outline-primary btn-sm') }} 16 |
17 |

18 | {% else %} 19 |

20 |

21 | {{ form.hidden_tag() }} 22 | {{ form.submit(value=_('Unfollow'), class_='btn btn-outline-primary btn-sm') }} 23 |
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 | 8 | 29 | 30 |
4 | 5 | 6 | 7 | 9 | {% set user_link %} 10 | 11 | {{ post.author.username }} 12 | 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 |
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 |
53 | {{ form.hidden_tag() }} 54 | {%- for field, errors in form.errors.items() %} 55 | {%- if form[field].widget.input_type == 'hidden' %} 56 | {%- for error in errors %} 57 |
{{ error }}
58 | {%- endfor %} 59 | {%- endif %} 60 | {%- endfor %} 61 | 62 | {% set ns = namespace(first_field=true) %} 63 | {%- for field in form %} 64 | {% if field.widget.input_type != 'hidden' -%} 65 | {{ form_field(field, ns.first_field) }} 66 | {% set ns.first_field = false %} 67 | {%- endif %} 68 | {%- endfor %} 69 |
70 | {% endmacro %} 71 | -------------------------------------------------------------------------------- /app/templates/user.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 | 6 | 7 | 38 | 39 |
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 |

22 | {{ form.hidden_tag() }} 23 | {{ form.submit(value=_('Follow'), class_='btn btn-primary') }} 24 |
25 |

26 | {% else %} 27 |

28 |

29 | {{ form.hidden_tag() }} 30 | {{ form.submit(value=_('Unfollow'), class_='btn btn-primary') }} 31 |
32 |

33 | {% endif %} 34 | {% if user != current_user %} 35 |

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

36 | {% endif %} 37 |
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 | 76 | {% endfor %} 77 | {% endif %} 78 | {% endwith %} 79 | {% endif %} 80 | 81 | {% with messages = get_flashed_messages() %} 82 | {% if messages %} 83 | {% for message in messages %} 84 | 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 | --------------------------------------------------------------------------------