diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000000000000000000000000000000000000..6dc6d3f0333f66b7ace51ce0d2ffe7164caec431 --- /dev/null +++ b/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=main.py +FLASK_DEBUG=1 diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed929afb064bc79bf3b1d33e8691164a9bcaec75 Binary files /dev/null and b/__pycache__/config.cpython-311.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3bd074bcd721178e2b9300d3fcd1fcd02e5ae78 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99a6a05acf7b73295f7bfdee14c771d50e15d86c Binary files /dev/null and b/__pycache__/main.cpython-311.pyc differ diff --git a/app.db b/app.db new file mode 100644 index 0000000000000000000000000000000000000000..c50ddce4fa4bcd21f467d098600bee57e329982f Binary files /dev/null and b/app.db differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..49fad10ba2af395b8e028fc594df6ffd97d3efaf --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,87 @@ +import logging +from logging.handlers import SMTPHandler, RotatingFileHandler +import os +from flask import Flask, request, current_app +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_mail import Mail +from flask_moment import Moment +from flask_babel import Babel, lazy_gettext as _l +from elasticsearch import Elasticsearch +from config import Config + + +def get_locale(): + return request.accept_languages.best_match(current_app.config['LANGUAGES']) + + +db = SQLAlchemy() +migrate = Migrate() +login = LoginManager() +login.login_view = 'auth.login' +login.login_message = _l('Please log in to access this page.') +mail = Mail() +moment = Moment() +babel = Babel() + + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + migrate.init_app(app, db) + login.init_app(app) + mail.init_app(app) + moment.init_app(app) + babel.init_app(app, locale_selector=get_locale) + app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ + if app.config['ELASTICSEARCH_URL'] else None + + from app.errors import bp as errors_bp + app.register_blueprint(errors_bp) + + from app.auth import bp as auth_bp + app.register_blueprint(auth_bp, url_prefix='/auth') + + from app.main import bp as main_bp + app.register_blueprint(main_bp) + + from app.cli import bp as cli_bp + app.register_blueprint(cli_bp) + + if not app.debug and not app.testing: + if app.config['MAIL_SERVER']: + auth = None + if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: + auth = (app.config['MAIL_USERNAME'], + app.config['MAIL_PASSWORD']) + secure = None + if app.config['MAIL_USE_TLS']: + secure = () + mail_handler = SMTPHandler( + mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), + fromaddr='no-reply@' + app.config['MAIL_SERVER'], + toaddrs=app.config['ADMINS'], subject='Microblog Failure', + credentials=auth, secure=secure) + mail_handler.setLevel(logging.ERROR) + app.logger.addHandler(mail_handler) + + if not os.path.exists('logs'): + os.mkdir('logs') + file_handler = RotatingFileHandler('logs/microblog.log', + maxBytes=10240, backupCount=10) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s: %(message)s ' + '[in %(pathname)s:%(lineno)d]')) + file_handler.setLevel(logging.INFO) + app.logger.addHandler(file_handler) + + app.logger.setLevel(logging.INFO) + app.logger.info('Microblog startup') + + return app + + +from app import models diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2821da322da266b3b2c897d052b564ce8eb476ce Binary files /dev/null and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..720e25cf3a280a01147a3c93d0cc29254404461f Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/cli.cpython-311.pyc b/app/__pycache__/cli.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08bb5c52b6e54c2b7120c902270b10d80e754ebf Binary files /dev/null and b/app/__pycache__/cli.cpython-311.pyc differ diff --git a/app/__pycache__/cli.cpython-312.pyc b/app/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d81d45aa0025f4f34c246b3686dbecf25eb92827 Binary files /dev/null and b/app/__pycache__/cli.cpython-312.pyc differ diff --git a/app/__pycache__/email.cpython-311.pyc b/app/__pycache__/email.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e36cab81a757c4c41bf67a58f7e95e71b4fa02d0 Binary files /dev/null and b/app/__pycache__/email.cpython-311.pyc differ diff --git a/app/__pycache__/email.cpython-312.pyc b/app/__pycache__/email.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c80b7ee1246815664c51f9e2d67b895d6dbec0e Binary files /dev/null and b/app/__pycache__/email.cpython-312.pyc differ diff --git a/app/__pycache__/models.cpython-311.pyc b/app/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..68bd16395dd387885f2b11ddc4ce0f0b096d2b5e Binary files /dev/null and b/app/__pycache__/models.cpython-311.pyc differ diff --git a/app/__pycache__/models.cpython-312.pyc b/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aae97987cfb947c16f5d22a3cf1b794b47d64387 Binary files /dev/null and b/app/__pycache__/models.cpython-312.pyc differ diff --git a/app/__pycache__/search.cpython-311.pyc b/app/__pycache__/search.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88e0e0a99c8853a90bcc84e11b18c263d61c93bb Binary files /dev/null and b/app/__pycache__/search.cpython-311.pyc differ diff --git a/app/__pycache__/search.cpython-312.pyc b/app/__pycache__/search.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a59137bd2e284b731cecf29e5ae92b58a3eb22d4 Binary files /dev/null and b/app/__pycache__/search.cpython-312.pyc differ diff --git a/app/__pycache__/translate.cpython-311.pyc b/app/__pycache__/translate.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1152003daf49de67a75f832054cf56d3fb8cb4f Binary files /dev/null and b/app/__pycache__/translate.cpython-311.pyc differ diff --git a/app/__pycache__/translate.cpython-312.pyc b/app/__pycache__/translate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb026ca523142bb9aa76ec579e5b9c1e5eab61b3 Binary files /dev/null and b/app/__pycache__/translate.cpython-312.pyc differ diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..088b0336be101d83b44e41eac8b6002bb56018fa --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes diff --git a/app/auth/__pycache__/__init__.cpython-311.pyc b/app/auth/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6226e0c9158245f7dbabbb152b5285dbc2893b2a Binary files /dev/null and b/app/auth/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/auth/__pycache__/__init__.cpython-312.pyc b/app/auth/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27c3a2a2855a43e5a690f1dfc441be3425f4078a Binary files /dev/null and b/app/auth/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/auth/__pycache__/email.cpython-311.pyc b/app/auth/__pycache__/email.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0aaf6ab4ea13a537e6935c7f6ebcf5af3cb9deb7 Binary files /dev/null and b/app/auth/__pycache__/email.cpython-311.pyc differ diff --git a/app/auth/__pycache__/email.cpython-312.pyc b/app/auth/__pycache__/email.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b332c91cc8fa0585c1a566000db1cd3eb774babb Binary files /dev/null and b/app/auth/__pycache__/email.cpython-312.pyc differ diff --git a/app/auth/__pycache__/forms.cpython-311.pyc b/app/auth/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80450bf8ff1de2900edfe84fa3dcb4b1db83fa3c Binary files /dev/null and b/app/auth/__pycache__/forms.cpython-311.pyc differ diff --git a/app/auth/__pycache__/forms.cpython-312.pyc b/app/auth/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0b9ba6ec1f205472bb2775bf13d681a673b1fff Binary files /dev/null and b/app/auth/__pycache__/forms.cpython-312.pyc differ diff --git a/app/auth/__pycache__/routes.cpython-311.pyc b/app/auth/__pycache__/routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13e7d7f0a87f3c3bb9a68ba766539f7d7dc11cf6 Binary files /dev/null and b/app/auth/__pycache__/routes.cpython-311.pyc differ diff --git a/app/auth/__pycache__/routes.cpython-312.pyc b/app/auth/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b2f62f9dbd487aad37172fa851918876b21313ba Binary files /dev/null and b/app/auth/__pycache__/routes.cpython-312.pyc differ diff --git a/app/auth/email.py b/app/auth/email.py new file mode 100644 index 0000000000000000000000000000000000000000..98755acee6921b2f049783b8908389788f106e79 --- /dev/null +++ b/app/auth/email.py @@ -0,0 +1,14 @@ +from flask import render_template, current_app +from flask_babel import _ +from app.email import send_email + + +def send_password_reset_email(user): + token = user.get_reset_password_token() + send_email(_('[Microblog] Reset Your Password'), + sender=current_app.config['ADMINS'][0], + recipients=[user.email], + text_body=render_template('email/reset_password.txt', + user=user, token=token), + html_body=render_template('email/reset_password.html', + user=user, token=token)) diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..d8a4bad17f5ad45256d4aabe30020c0563c9ea80 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,49 @@ +from flask_wtf import FlaskForm +from flask_babel import _, lazy_gettext as _l +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo +import sqlalchemy as sa +from app import db +from app.models import User + + +class LoginForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + password = PasswordField(_l('Password'), validators=[DataRequired()]) + remember_me = BooleanField(_l('Remember Me')) + submit = SubmitField(_l('Sign In')) + + +class RegistrationForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + email = StringField(_l('Email'), validators=[DataRequired(), Email()]) + password = PasswordField(_l('Password'), validators=[DataRequired()]) + password2 = PasswordField( + _l('Repeat Password'), validators=[DataRequired(), + EqualTo('password')]) + submit = SubmitField(_l('Register')) + + def validate_username(self, username): + user = db.session.scalar(sa.select(User).where( + User.username == username.data)) + if user is not None: + raise ValidationError(_('Please use a different username.')) + + def validate_email(self, email): + user = db.session.scalar(sa.select(User).where( + User.email == email.data)) + if user is not None: + raise ValidationError(_('Please use a different email address.')) + + +class ResetPasswordRequestForm(FlaskForm): + email = StringField(_l('Email'), validators=[DataRequired(), Email()]) + submit = SubmitField(_l('Request Password Reset')) + + +class ResetPasswordForm(FlaskForm): + password = PasswordField(_l('Password'), validators=[DataRequired()]) + password2 = PasswordField( + _l('Repeat Password'), validators=[DataRequired(), + EqualTo('password')]) + submit = SubmitField(_l('Request Password Reset')) diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..79360ff7bf13c519936096a43351af1e26986ce4 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,85 @@ +from flask import render_template, redirect, url_for, flash, request +from urllib.parse import urlsplit +from flask_login import login_user, logout_user, current_user +from flask_babel import _ +import sqlalchemy as sa +from app import db +from app.auth import bp +from app.auth.forms import LoginForm, RegistrationForm, \ + ResetPasswordRequestForm, ResetPasswordForm +from app.models import User +from app.auth.email import send_password_reset_email + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = LoginForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == form.username.data)) + if user is None or not user.check_password(form.password.data): + flash(_('Invalid username or password')) + return redirect(url_for('auth.login')) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or urlsplit(next_page).netloc != '': + next_page = url_for('main.index') + return redirect(next_page) + return render_template('auth/login.html', title=_('Sign In'), form=form) + + +@bp.route('/logout') +def logout(): + logout_user() + return redirect(url_for('main.index')) + + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data, email=form.email.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash(_('Congratulations, you are now a registered user!')) + return redirect(url_for('auth.login')) + return render_template('auth/register.html', title=_('Register'), + form=form) + + +@bp.route('/reset_password_request', methods=['GET', 'POST']) +def reset_password_request(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = ResetPasswordRequestForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.email == form.email.data)) + if user: + send_password_reset_email(user) + flash( + _('Check your email for the instructions to reset your password')) + return redirect(url_for('auth.login')) + return render_template('auth/reset_password_request.html', + title=_('Reset Password'), form=form) + + +@bp.route('/reset_password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + user = User.verify_reset_password_token(token) + if not user: + return redirect(url_for('main.index')) + form = ResetPasswordForm() + if form.validate_on_submit(): + user.set_password(form.password.data) + db.session.commit() + flash(_('Your password has been reset.')) + return redirect(url_for('auth.login')) + return render_template('auth/reset_password.html', form=form) diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..25e2a2750745f668fa56569fc80406b99f5ff488 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,40 @@ +import os +from flask import Blueprint +import click + +bp = Blueprint('cli', __name__, cli_group=None) + + +@bp.cli.group() +def translate(): + """Translation and localization commands.""" + pass + + +@translate.command() +@click.argument('lang') +def init(lang): + """Initialize a new language.""" + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system( + 'pybabel init -i messages.pot -d app/translations -l ' + lang): + raise RuntimeError('init command failed') + os.remove('messages.pot') + + +@translate.command() +def update(): + """Update all languages.""" + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system('pybabel update -i messages.pot -d app/translations'): + raise RuntimeError('update command failed') + os.remove('messages.pot') + + +@translate.command() +def compile(): + """Compile all languages.""" + if os.system('pybabel compile -d app/translations'): + raise RuntimeError('compile command failed') diff --git a/app/email.py b/app/email.py new file mode 100644 index 0000000000000000000000000000000000000000..ee23da84c7a5796b699ca90f8afbf12f208acebe --- /dev/null +++ b/app/email.py @@ -0,0 +1,17 @@ +from threading import Thread +from flask import current_app +from flask_mail import Message +from app import mail + + +def send_async_email(app, msg): + with app.app_context(): + mail.send(msg) + + +def send_email(subject, sender, recipients, text_body, html_body): + msg = Message(subject, sender=sender, recipients=recipients) + msg.body = text_body + msg.html = html_body + Thread(target=send_async_email, + args=(current_app._get_current_object(), msg)).start() diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5701c1d1101cd7f1905c87637a323ebf778df567 --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from app.errors import handlers diff --git a/app/errors/__pycache__/__init__.cpython-311.pyc b/app/errors/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52b3b773cca4b6e789d6a3bf471a22adc93628f7 Binary files /dev/null and b/app/errors/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/errors/__pycache__/__init__.cpython-312.pyc b/app/errors/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c68a4990035ec5d61f65987a8923778448c1af1 Binary files /dev/null and b/app/errors/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/errors/__pycache__/handlers.cpython-311.pyc b/app/errors/__pycache__/handlers.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f459a8950a742bdaebc8c606fed54e6bb553f1d8 Binary files /dev/null and b/app/errors/__pycache__/handlers.cpython-311.pyc differ diff --git a/app/errors/__pycache__/handlers.cpython-312.pyc b/app/errors/__pycache__/handlers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d4e272b5a49108c16024537cfd36179ba51bb3e Binary files /dev/null and b/app/errors/__pycache__/handlers.cpython-312.pyc differ diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..4a40ad9e51a862d156f25b7b880b72bf63d95ad0 --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,14 @@ +from flask import render_template +from app import db +from app.errors import bp + + +@bp.app_errorhandler(404) +def not_found_error(error): + return render_template('errors/404.html'), 404 + + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('errors/500.html'), 500 diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3b580b07309ba57e11b149e9741888a4ba2e8cf4 --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from app.main import routes diff --git a/app/main/__pycache__/__init__.cpython-311.pyc b/app/main/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ccd627c4557672cfc3843327e47b97187affc3a6 Binary files /dev/null and b/app/main/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/main/__pycache__/__init__.cpython-312.pyc b/app/main/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a774eb23904cf7268feec1a7f88f122d872cedf Binary files /dev/null and b/app/main/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/main/__pycache__/forms.cpython-311.pyc b/app/main/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c2400d471ca689c4f24ef859d06cfb31b95bf08 Binary files /dev/null and b/app/main/__pycache__/forms.cpython-311.pyc differ diff --git a/app/main/__pycache__/forms.cpython-312.pyc b/app/main/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9142398d3dc4731feb82656f4e5c253ea763bca0 Binary files /dev/null and b/app/main/__pycache__/forms.cpython-312.pyc differ diff --git a/app/main/__pycache__/routes.cpython-311.pyc b/app/main/__pycache__/routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5241aad978999e0563b8951f803e44c62ec76d06 Binary files /dev/null and b/app/main/__pycache__/routes.cpython-311.pyc differ diff --git a/app/main/__pycache__/routes.cpython-312.pyc b/app/main/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f34b427c3571ee458df49abb2323ec07299e2785 Binary files /dev/null and b/app/main/__pycache__/routes.cpython-312.pyc differ diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..b28d8f0c7ff669497428400a4a36161b7929e58e --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,47 @@ +from flask import request +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField +from wtforms.validators import ValidationError, DataRequired, Length +import sqlalchemy as sa +from flask_babel import _, lazy_gettext as _l +from app import db +from app.models import User + + +class EditProfileForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + about_me = TextAreaField(_l('About me'), + validators=[Length(min=0, max=140)]) + submit = SubmitField(_l('Submit')) + + def __init__(self, original_username, *args, **kwargs): + super().__init__(*args, **kwargs) + self.original_username = original_username + + def validate_username(self, username): + if username.data != self.original_username: + user = db.session.scalar(sa.select(User).where( + User.username == self.username.data)) + if user is not None: + raise ValidationError(_('Please use a different username.')) + + +class EmptyForm(FlaskForm): + submit = SubmitField('Submit') + + +class PostForm(FlaskForm): + post = TextAreaField(_l('Say something'), validators=[ + DataRequired(), Length(min=1, max=140)]) + submit = SubmitField(_l('Submit')) + + +class SearchForm(FlaskForm): + q = StringField(_l('Search'), validators=[DataRequired()]) + + def __init__(self, *args, **kwargs): + if 'formdata' not in kwargs: + kwargs['formdata'] = request.args + if 'meta' not in kwargs: + kwargs['meta'] = {'csrf': False} + super(SearchForm, self).__init__(*args, **kwargs) diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..0f0824c904d97a81f744be5f70a7aa037953bbf5 --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,169 @@ +from datetime import datetime, timezone +from flask import render_template, flash, redirect, url_for, request, g, \ + current_app +from flask_login import current_user, login_required +from flask_babel import _, get_locale +import sqlalchemy as sa +from langdetect import detect, LangDetectException +from app import db +from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm +from app.models import User, Post +from app.translate import translate +from app.main import bp + + +@bp.before_app_request +def before_request(): + if current_user.is_authenticated: + current_user.last_seen = datetime.now(timezone.utc) + db.session.commit() + g.search_form = SearchForm() + g.locale = str(get_locale()) + + +@bp.route('/', methods=['GET', 'POST']) +@bp.route('/index', methods=['GET', 'POST']) +@login_required +def index(): + form = PostForm() + if form.validate_on_submit(): + try: + language = detect(form.post.data) + except LangDetectException: + language = '' + post = Post(body=form.post.data, author=current_user, + language=language) + db.session.add(post) + db.session.commit() + flash(_('Your post is now live!')) + return redirect(url_for('main.index')) + page = request.args.get('page', 1, type=int) + posts = db.paginate(current_user.following_posts(), page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.index', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('main.index', page=posts.prev_num) \ + if posts.has_prev else None + return render_template('index.html', title=_('Home'), form=form, + posts=posts.items, next_url=next_url, + prev_url=prev_url) + + +@bp.route('/explore') +@login_required +def explore(): + page = request.args.get('page', 1, type=int) + query = sa.select(Post).order_by(Post.timestamp.desc()) + posts = db.paginate(query, page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.explore', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('main.explore', page=posts.prev_num) \ + if posts.has_prev else None + return render_template('index.html', title=_('Explore'), + posts=posts.items, next_url=next_url, + prev_url=prev_url) + + +@bp.route('/user/') +@login_required +def user(username): + user = db.first_or_404(sa.select(User).where(User.username == username)) + page = request.args.get('page', 1, type=int) + query = user.posts.select().order_by(Post.timestamp.desc()) + posts = db.paginate(query, page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.user', username=user.username, + page=posts.next_num) if posts.has_next else None + prev_url = url_for('main.user', username=user.username, + page=posts.prev_num) if posts.has_prev else None + form = EmptyForm() + return render_template('user.html', user=user, posts=posts.items, + next_url=next_url, prev_url=prev_url, form=form) + + +@bp.route('/edit_profile', methods=['GET', 'POST']) +@login_required +def edit_profile(): + form = EditProfileForm(current_user.username) + if form.validate_on_submit(): + current_user.username = form.username.data + current_user.about_me = form.about_me.data + db.session.commit() + flash(_('Your changes have been saved.')) + return redirect(url_for('main.edit_profile')) + elif request.method == 'GET': + form.username.data = current_user.username + form.about_me.data = current_user.about_me + return render_template('edit_profile.html', title=_('Edit Profile'), + form=form) + + +@bp.route('/follow/', methods=['POST']) +@login_required +def follow(username): + form = EmptyForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == username)) + if user is None: + flash(_('User %(username)s not found.', username=username)) + return redirect(url_for('main.index')) + if user == current_user: + flash(_('You cannot follow yourself!')) + return redirect(url_for('main.user', username=username)) + current_user.follow(user) + db.session.commit() + flash(_('You are following %(username)s!', username=username)) + return redirect(url_for('main.user', username=username)) + else: + return redirect(url_for('main.index')) + + +@bp.route('/unfollow/', methods=['POST']) +@login_required +def unfollow(username): + form = EmptyForm() + if form.validate_on_submit(): + user = db.session.scalar( + sa.select(User).where(User.username == username)) + if user is None: + flash(_('User %(username)s not found.', username=username)) + return redirect(url_for('main.index')) + if user == current_user: + flash(_('You cannot unfollow yourself!')) + return redirect(url_for('main.user', username=username)) + current_user.unfollow(user) + db.session.commit() + flash(_('You are not following %(username)s.', username=username)) + return redirect(url_for('main.user', username=username)) + else: + return redirect(url_for('main.index')) + + +@bp.route('/translate', methods=['POST']) +@login_required +def translate_text(): + data = request.get_json() + return {'text': translate(data['text'], + data['source_language'], + data['dest_language'])} + + +@bp.route('/search') +@login_required +def search(): + if not g.search_form.validate(): + return redirect(url_for('main.explore')) + page = request.args.get('page', 1, type=int) + posts, total = Post.search(g.search_form.q.data, page, + current_app.config['POSTS_PER_PAGE']) + next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \ + if total > page * current_app.config['POSTS_PER_PAGE'] else None + prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \ + if page > 1 else None + return render_template('search.html', title=_('Search'), posts=posts, + next_url=next_url, prev_url=prev_url) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000000000000000000000000000000000000..752392adc190c1ef3417efaf97282a2092e6be91 --- /dev/null +++ b/app/models.py @@ -0,0 +1,174 @@ +from datetime import datetime, timezone +from hashlib import md5 +from time import time +from typing import Optional +import sqlalchemy as sa +import sqlalchemy.orm as so +from flask import current_app +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +import jwt +from app import db, login +from app.search import add_to_index, remove_from_index, query_index + + +class SearchableMixin: + @classmethod + def search(cls, expression, page, per_page): + ids, total = query_index(cls.__tablename__, expression, page, per_page) + if total == 0: + return [], 0 + when = [] + for i in range(len(ids)): + when.append((ids[i], i)) + query = sa.select(cls).where(cls.id.in_(ids)).order_by( + db.case(*when, value=cls.id)) + return db.session.scalars(query), total + + @classmethod + def before_commit(cls, session): + session._changes = { + 'add': list(session.new), + 'update': list(session.dirty), + 'delete': list(session.deleted) + } + + @classmethod + def after_commit(cls, session): + for obj in session._changes['add']: + if isinstance(obj, SearchableMixin): + add_to_index(obj.__tablename__, obj) + for obj in session._changes['update']: + if isinstance(obj, SearchableMixin): + add_to_index(obj.__tablename__, obj) + for obj in session._changes['delete']: + if isinstance(obj, SearchableMixin): + remove_from_index(obj.__tablename__, obj) + session._changes = None + + @classmethod + def reindex(cls): + for obj in db.session.scalars(sa.select(cls)): + add_to_index(cls.__tablename__, obj) + + +db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) +db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) + + +followers = sa.Table( + 'followers', + db.metadata, + sa.Column('follower_id', sa.Integer, sa.ForeignKey('user.id'), + primary_key=True), + sa.Column('followed_id', sa.Integer, sa.ForeignKey('user.id'), + primary_key=True) +) + + +class User(UserMixin, db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, + unique=True) + email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, + unique=True) + password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256)) + about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140)) + last_seen: so.Mapped[Optional[datetime]] = so.mapped_column( + default=lambda: datetime.now(timezone.utc)) + + posts: so.WriteOnlyMapped['Post'] = so.relationship( + back_populates='author') + following: so.WriteOnlyMapped['User'] = so.relationship( + secondary=followers, primaryjoin=(followers.c.follower_id == id), + secondaryjoin=(followers.c.followed_id == id), + back_populates='followers') + followers: so.WriteOnlyMapped['User'] = so.relationship( + secondary=followers, primaryjoin=(followers.c.followed_id == id), + secondaryjoin=(followers.c.follower_id == id), + back_populates='following') + + def __repr__(self): + return ''.format(self.username) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def avatar(self, size): + digest = md5(self.email.lower().encode('utf-8')).hexdigest() + return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}' + + def follow(self, user): + if not self.is_following(user): + self.following.add(user) + + def unfollow(self, user): + if self.is_following(user): + self.following.remove(user) + + def is_following(self, user): + query = self.following.select().where(User.id == user.id) + return db.session.scalar(query) is not None + + def followers_count(self): + query = sa.select(sa.func.count()).select_from( + self.followers.select().subquery()) + return db.session.scalar(query) + + def following_count(self): + query = sa.select(sa.func.count()).select_from( + self.following.select().subquery()) + return db.session.scalar(query) + + def following_posts(self): + Author = so.aliased(User) + Follower = so.aliased(User) + return ( + sa.select(Post) + .join(Post.author.of_type(Author)) + .join(Author.followers.of_type(Follower), isouter=True) + .where(sa.or_( + Follower.id == self.id, + Author.id == self.id, + )) + .group_by(Post) + .order_by(Post.timestamp.desc()) + ) + + def get_reset_password_token(self, expires_in=600): + return jwt.encode( + {'reset_password': self.id, 'exp': time() + expires_in}, + current_app.config['SECRET_KEY'], algorithm='HS256') + + @staticmethod + def verify_reset_password_token(token): + try: + id = jwt.decode(token, current_app.config['SECRET_KEY'], + algorithms=['HS256'])['reset_password'] + except Exception: + return + return db.session.get(User, id) + + +@login.user_loader +def load_user(id): + return db.session.get(User, int(id)) + + +class Post(SearchableMixin, db.Model): + __searchable__ = ['body'] + id: so.Mapped[int] = so.mapped_column(primary_key=True) + body: so.Mapped[str] = so.mapped_column(sa.String(140)) + timestamp: so.Mapped[datetime] = so.mapped_column( + index=True, default=lambda: datetime.now(timezone.utc)) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + language: so.Mapped[Optional[str]] = so.mapped_column(sa.String(5)) + + author: so.Mapped[User] = so.relationship(back_populates='posts') + + def __repr__(self): + return ''.format(self.body) diff --git a/app/search.py b/app/search.py new file mode 100644 index 0000000000000000000000000000000000000000..51c5c9609fc8f7d50dd1da86b41eba7a255b16f3 --- /dev/null +++ b/app/search.py @@ -0,0 +1,28 @@ +from flask import current_app + + +def add_to_index(index, model): + if not current_app.elasticsearch: + return + payload = {} + for field in model.__searchable__: + payload[field] = getattr(model, field) + current_app.elasticsearch.index(index=index, id=model.id, document=payload) + + +def remove_from_index(index, model): + if not current_app.elasticsearch: + return + current_app.elasticsearch.delete(index=index, id=model.id) + + +def query_index(index, query, page, per_page): + if not current_app.elasticsearch: + return [], 0 + search = current_app.elasticsearch.search( + index=index, + query={'multi_match': {'query': query, 'fields': ['*']}}, + from_=(page - 1) * per_page, + size=per_page) + ids = [int(hit['_id']) for hit in search['hits']['hits']] + return ids, search['hits']['total']['value'] diff --git a/app/static/loading.gif b/app/static/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..d0bce1542342e912da81a2c260562df172f30d73 Binary files /dev/null and b/app/static/loading.gif differ diff --git a/app/templates/_post.html b/app/templates/_post.html new file mode 100644 index 0000000000000000000000000000000000000000..8c0a9cd9523978d14b71b18e127aafd77728a620 --- /dev/null +++ b/app/templates/_post.html @@ -0,0 +1,30 @@ + + + + + +
+ + + + + {% set user_link %} + + {{ post.author.username }} + + {% endset %} + {{ _('%(username)s said %(when)s', + username=user_link, when=moment(post.timestamp).fromNow()) }} +
+ {{ post.body }} + {% if post.language and post.language != g.locale %} +

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

{{ _('Sign In') }}

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

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

+

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

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

{{ _('Register') }}

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

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

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

{{ _('Reset Password') }}

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

{{ _('Edit Profile') }}

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

Dear {{ user.username }},

+

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

+

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

+

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

+

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

+

Sincerely,

+

The Microblog Team

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

{{ _('Not Found') }}

+

{{ _('Back') }}

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

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

+

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

+

{{ _('Back') }}

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

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

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

{{ _('Search Results') }}

+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html new file mode 100644 index 0000000000000000000000000000000000000000..ba82ee48f77cc7166daf4b90cf5ae242af294048 --- /dev/null +++ b/app/templates/user.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block content %} + + + + + +
+

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

+ {% if user.about_me %}

{{ user.about_me }}

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

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

+ {% endif %} +

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

+ {% if user == current_user %} +

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

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

+

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

+ {% else %} +

+

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

+ {% endif %} +
+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/app/translate.py b/app/translate.py new file mode 100644 index 0000000000000000000000000000000000000000..b51a17a8b1d9b600497c93ad3c02f43c022721af --- /dev/null +++ b/app/translate.py @@ -0,0 +1,21 @@ +import requests +from flask import current_app +from flask_babel import _ + + +def translate(text, source_language, dest_language): + if 'MS_TRANSLATOR_KEY' not in current_app.config or \ + not current_app.config['MS_TRANSLATOR_KEY']: + return _('Error: the translation service is not configured.') + auth = { + 'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY'], + 'Ocp-Apim-Subscription-Region': 'westus' + } + r = requests.post( + 'https://api.cognitive.microsofttranslator.com' + '/translate?api-version=3.0&from={}&to={}'.format( + source_language, dest_language), headers=auth, json=[ + {'Text': text}]) + if r.status_code != 200: + return _('Error: the translation service failed.') + return r.json()[0]['translations'][0]['text'] diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po new file mode 100644 index 0000000000000000000000000000000000000000..df667c98a9f7e01fe29647eed2ae0dd1bd63acab --- /dev/null +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -0,0 +1,275 @@ +# Spanish translations for PROJECT. +# Copyright (C) 2017 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2017-11-25 18:23-0800\n" +"PO-Revision-Date: 2017-09-29 23:25-0700\n" +"Last-Translator: FULL NAME \n" +"Language: es\n" +"Language-Team: es \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.5.1\n" + +#: app/__init__.py:18 +msgid "Please log in to access this page." +msgstr "Por favor ingrese para acceder a esta página." + +#: app/translate.py:10 +msgid "Error: the translation service is not configured." +msgstr "Error: el servicio de traducciones no está configurado." + +#: app/translate.py:18 +msgid "Error: the translation service failed." +msgstr "Error el servicio de traducciones ha fallado." + +#: app/auth/email.py:8 +msgid "[Microblog] Reset Your Password" +msgstr "[Microblog] Nueva Contraseña" + +#: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10 +msgid "Username" +msgstr "Nombre de usuario" + +#: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42 +msgid "Password" +msgstr "Contraseña" + +#: app/auth/forms.py:12 +msgid "Remember Me" +msgstr "Recordarme" + +#: app/auth/forms.py:13 app/templates/auth/login.html:5 +msgid "Sign In" +msgstr "Ingresar" + +#: app/auth/forms.py:18 app/auth/forms.py:37 +msgid "Email" +msgstr "Email" + +#: app/auth/forms.py:21 app/auth/forms.py:44 +msgid "Repeat Password" +msgstr "Repetir Contraseña" + +#: app/auth/forms.py:23 app/templates/auth/register.html:5 +msgid "Register" +msgstr "Registrarse" + +#: app/auth/forms.py:28 app/main/forms.py:23 +msgid "Please use a different username." +msgstr "Por favor use un nombre de usuario diferente." + +#: app/auth/forms.py:33 +msgid "Please use a different email address." +msgstr "Por favor use una dirección de email diferente." + +#: app/auth/forms.py:38 app/auth/forms.py:46 +msgid "Request Password Reset" +msgstr "Pedir una nueva contraseña" + +#: app/auth/routes.py:20 +msgid "Invalid username or password" +msgstr "Nombre de usuario o contraseña inválidos" + +#: app/auth/routes.py:46 +msgid "Congratulations, you are now a registered user!" +msgstr "¡Felicitaciones, ya eres un usuario registrado!" + +#: app/auth/routes.py:61 +msgid "Check your email for the instructions to reset your password" +msgstr "Busca en tu email las instrucciones para crear una nueva contraseña" + +#: app/auth/routes.py:78 +msgid "Your password has been reset." +msgstr "Tu contraseña ha sido cambiada." + +#: app/main/forms.py:11 +msgid "About me" +msgstr "Acerca de mí" + +#: app/main/forms.py:13 app/main/forms.py:28 +msgid "Submit" +msgstr "Enviar" + +#: app/main/forms.py:27 +msgid "Say something" +msgstr "Dí algo" + +#: app/main/forms.py:32 +msgid "Search" +msgstr "Buscar" + +#: app/main/routes.py:36 +msgid "Your post is now live!" +msgstr "¡Tu artículo ha sido publicado!" + +#: app/main/routes.py:87 +msgid "Your changes have been saved." +msgstr "Tus cambios han sido salvados." + +#: app/main/routes.py:92 app/templates/edit_profile.html:5 +msgid "Edit Profile" +msgstr "Editar Perfil" + +#: app/main/routes.py:101 app/main/routes.py:117 +#, python-format +msgid "User %(username)s not found." +msgstr "El usuario %(username)s no ha sido encontrado." + +#: app/main/routes.py:104 +msgid "You cannot follow yourself!" +msgstr "¡No te puedes seguir a tí mismo!" + +#: app/main/routes.py:108 +#, python-format +msgid "You are following %(username)s!" +msgstr "¡Ahora estás siguiendo a %(username)s!" + +#: app/main/routes.py:120 +msgid "You cannot unfollow yourself!" +msgstr "¡No te puedes dejar de seguir a tí mismo!" + +#: app/main/routes.py:124 +#, python-format +msgid "You are not following %(username)s." +msgstr "No estás siguiendo a %(username)s." + +#: app/templates/_post.html:14 +#, python-format +msgid "%(username)s said %(when)s" +msgstr "%(username)s dijo %(when)s" + +#: app/templates/_post.html:25 +msgid "Translate" +msgstr "Traducir" + +#: app/templates/base.html:4 +msgid "Welcome to Microblog" +msgstr "Bienvenido a Microblog" + +#: app/templates/base.html:21 +msgid "Home" +msgstr "Inicio" + +#: app/templates/base.html:22 +msgid "Explore" +msgstr "Explorar" + +#: app/templates/base.html:33 +msgid "Login" +msgstr "Ingresar" + +#: app/templates/base.html:35 +msgid "Profile" +msgstr "Perfil" + +#: app/templates/base.html:36 +msgid "Logout" +msgstr "Salir" + +#: app/templates/base.html:73 +msgid "Error: Could not contact server." +msgstr "Error: el servidor no pudo ser contactado." + +#: app/templates/index.html:5 +#, python-format +msgid "Hi, %(username)s!" +msgstr "¡Hola, %(username)s!" + +#: app/templates/index.html:17 app/templates/user.html:31 +msgid "Newer posts" +msgstr "Artículos siguientes" + +#: app/templates/index.html:22 app/templates/user.html:36 +msgid "Older posts" +msgstr "Artículos previos" + +#: app/templates/search.html:4 +msgid "Search Results" +msgstr "Resultados de Búsqueda" + +#: app/templates/search.html:12 +msgid "Previous results" +msgstr "Resultados previos" + +#: app/templates/search.html:17 +msgid "Next results" +msgstr "Resultados próximos" + +#: app/templates/user.html:8 +msgid "User" +msgstr "Usuario" + +#: app/templates/user.html:11 +msgid "Last seen on" +msgstr "Última visita" + +#: app/templates/user.html:13 +#, python-format +msgid "%(count)d followers" +msgstr "%(count)d seguidores" + +#: app/templates/user.html:13 +#, python-format +msgid "%(count)d following" +msgstr "siguiendo a %(count)d" + +#: app/templates/user.html:15 +msgid "Edit your profile" +msgstr "Editar tu perfil" + +#: app/templates/user.html:17 +msgid "Follow" +msgstr "Seguir" + +#: app/templates/user.html:19 +msgid "Unfollow" +msgstr "Dejar de seguir" + +#: app/templates/auth/login.html:12 +msgid "New User?" +msgstr "¿Usuario Nuevo?" + +#: app/templates/auth/login.html:12 +msgid "Click to Register!" +msgstr "¡Haz click aquí para registrarte!" + +#: app/templates/auth/login.html:14 +msgid "Forgot Your Password?" +msgstr "¿Te olvidaste tu contraseña?" + +#: app/templates/auth/login.html:15 +msgid "Click to Reset It" +msgstr "Haz click aquí para pedir una nueva" + +#: app/templates/auth/reset_password.html:5 +msgid "Reset Your Password" +msgstr "Nueva Contraseña" + +#: app/templates/auth/reset_password_request.html:5 +msgid "Reset Password" +msgstr "Nueva Contraseña" + +#: app/templates/errors/404.html:4 +msgid "Not Found" +msgstr "Página No Encontrada" + +#: app/templates/errors/404.html:5 app/templates/errors/500.html:6 +msgid "Back" +msgstr "Atrás" + +#: app/templates/errors/500.html:4 +msgid "An unexpected error has occurred" +msgstr "Ha ocurrido un error inesperado" + +#: app/templates/errors/500.html:5 +msgid "The administrator has been notified. Sorry for the inconvenience!" +msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" + diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000000000000000000000000000000000000..b42c3d5770cbd2491426037419a2732498b4bfca --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: app/**.py] +[jinja2: app/templates/**.html] diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..1fd1a8c3e7fe77828898d2d7f4b46ffda7fa76d9 --- /dev/null +++ b/config.py @@ -0,0 +1,21 @@ +import os +from dotenv import load_dotenv + +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(basedir, '.env')) + + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'app.db') + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + ADMINS = ['your-email@example.com'] + LANGUAGES = ['en', 'es'] + MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') + ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') + POSTS_PER_PAGE = 25 diff --git a/logs/microblog.log b/logs/microblog.log new file mode 100644 index 0000000000000000000000000000000000000000..e1f8d22e97b35d61e95d7ba317b1208b05d17a86 --- /dev/null +++ b/logs/microblog.log @@ -0,0 +1 @@ +2024-03-21 10:30:32,263 INFO: Microblog startup [in C:\Users\nasma\OneDrive\Desktop\BlueChipTechnologiesAsia_Internship\Task2-flaskexercise\CHAPTER16\app\__init__.py:82] diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff9cf20f9758ea02fc45b837f89ffe8de90979a --- /dev/null +++ b/main.py @@ -0,0 +1,14 @@ +import sqlalchemy as sa +import sqlalchemy.orm as so +from app import create_app, db +from app.models import User, Post + +app = create_app() + + +@app.shell_context_processor +def make_shell_context(): + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post} +# For local system & cloud +if __name__ == "__main__": + app.run(port=5016) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000000000000000000000000000000000000..98e4f9c44effe479ed38c66ba922e7bcc672916f --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..f8ed4801f78bcb83cc6acb589508c1b24eda297a --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000000000000000000000000000000000000..23663ff2f54e6c4425953537976b175246c8a9e6 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..2c0156303a8df3ffdc9de87765bf801bf6bea4a5 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/2b017edaa91f_add_language_to_posts.py b/migrations/versions/2b017edaa91f_add_language_to_posts.py new file mode 100644 index 0000000000000000000000000000000000000000..260cbcb82d831d347ae598945c473200723f9358 --- /dev/null +++ b/migrations/versions/2b017edaa91f_add_language_to_posts.py @@ -0,0 +1,32 @@ +"""add language to posts + +Revision ID: 2b017edaa91f +Revises: ae346256b650 +Create Date: 2017-10-04 22:48:34.494465 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2b017edaa91f' +down_revision = 'ae346256b650' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('language', sa.String(length=5), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_column('language') + + # ### end Alembic commands ### diff --git a/migrations/versions/37f06a334dbf_new_fields_in_user_model.py b/migrations/versions/37f06a334dbf_new_fields_in_user_model.py new file mode 100644 index 0000000000000000000000000000000000000000..d633a5b9448500b0343ea924b948bce6412931be --- /dev/null +++ b/migrations/versions/37f06a334dbf_new_fields_in_user_model.py @@ -0,0 +1,34 @@ +"""new fields in user model + +Revision ID: 37f06a334dbf +Revises: 780739b227a7 +Create Date: 2017-09-14 10:54:13.865401 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '37f06a334dbf' +down_revision = '780739b227a7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('about_me', sa.String(length=140), nullable=True)) + batch_op.add_column(sa.Column('last_seen', sa.DateTime(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('last_seen') + batch_op.drop_column('about_me') + + # ### end Alembic commands ### diff --git a/migrations/versions/780739b227a7_posts_table.py b/migrations/versions/780739b227a7_posts_table.py new file mode 100644 index 0000000000000000000000000000000000000000..219178ad8fdf5e928f7d3f29eaee91e05d2137af --- /dev/null +++ b/migrations/versions/780739b227a7_posts_table.py @@ -0,0 +1,43 @@ +"""posts table + +Revision ID: 780739b227a7 +Revises: e517276bb1c2 +Create Date: 2017-09-11 12:23:25.496587 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '780739b227a7' +down_revision = 'e517276bb1c2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('post', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('body', sa.String(length=140), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_post_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_post_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_post_user_id')) + batch_op.drop_index(batch_op.f('ix_post_timestamp')) + + op.drop_table('post') + # ### end Alembic commands ### diff --git a/migrations/versions/ae346256b650_followers.py b/migrations/versions/ae346256b650_followers.py new file mode 100644 index 0000000000000000000000000000000000000000..f41f3c55fec482e674cebceb644c48ebbd5988ce --- /dev/null +++ b/migrations/versions/ae346256b650_followers.py @@ -0,0 +1,34 @@ +"""followers + +Revision ID: ae346256b650 +Revises: 37f06a334dbf +Create Date: 2017-09-17 15:41:30.211082 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ae346256b650' +down_revision = '37f06a334dbf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('followers', + sa.Column('follower_id', sa.Integer(), nullable=False), + sa.Column('followed_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('follower_id', 'followed_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('followers') + # ### end Alembic commands ### diff --git a/migrations/versions/e517276bb1c2_users_table.py b/migrations/versions/e517276bb1c2_users_table.py new file mode 100644 index 0000000000000000000000000000000000000000..bfd12ed77d56eb2fee832e68a8b2e88edd03ee4e --- /dev/null +++ b/migrations/versions/e517276bb1c2_users_table.py @@ -0,0 +1,42 @@ +"""users table + +Revision ID: e517276bb1c2 +Revises: +Create Date: 2017-09-11 11:23:05.566844 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e517276bb1c2' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password_hash', sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True) + batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_username')) + batch_op.drop_index(batch_op.f('ix_user_email')) + + op.drop_table('user') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..748314dd9d1eceef1121f1f6feef463bb9e94a91 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +aiosmtpd==1.4.4.post2 +alembic==1.12.1 +atpublic==4.0 +attrs==23.1.0 +Babel==2.13.1 +blinker==1.7.0 +certifi==2023.11.17 +charset-normalizer==3.3.2 +click==8.1.7 +dnspython==2.4.2 +elastic-transport==8.10.0 +elasticsearch==8.11.0 +email-validator==2.1.0.post1 +Flask==3.0.0 +flask-babel==4.0.0 +Flask-Login==0.6.3 +Flask-Mail==0.9.1 +Flask-Migrate==4.0.5 +Flask-Moment==1.0.5 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.1 +greenlet==3.0.1 +idna==3.4 +itsdangerous==2.1.2 +Jinja2==3.1.2 +langdetect==1.0.9 +Mako==1.3.0 +MarkupSafe==2.1.3 +packaging==23.2 +PyJWT==2.8.0 +python-dotenv==1.0.0 +pytz==2023.3.post1 +requests==2.31.0 +setuptools==68.2.2 +six==1.16.0 +SQLAlchemy==2.0.23 +typing_extensions==4.8.0 +urllib3==2.1.0 +Werkzeug==3.0.1 +WTForms==3.1.1