diff --git a/apps/flask/flask3-social-media/.flaskenv b/apps/flask/flask3-social-media/.flaskenv new file mode 100644 index 00000000..0973edd5 --- /dev/null +++ b/apps/flask/flask3-social-media/.flaskenv @@ -0,0 +1,3 @@ +FLASK_APP=microblog.py +FLASK_DEBUG=1 +FLASK_RUN_PORT=5001 diff --git a/apps/flask/flask3-social-media/.gitattributes b/apps/flask/flask3-social-media/.gitattributes new file mode 100644 index 00000000..efdba876 --- /dev/null +++ b/apps/flask/flask3-social-media/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf diff --git a/apps/flask/flask3-social-media/.gitignore b/apps/flask/flask3-social-media/.gitignore new file mode 100644 index 00000000..1e4f8360 --- /dev/null +++ b/apps/flask/flask3-social-media/.gitignore @@ -0,0 +1,40 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +venv +app.db +microblog.log* diff --git a/apps/flask/flask3-social-media/Dockerfile b/apps/flask/flask3-social-media/Dockerfile new file mode 100644 index 00000000..972b18b1 --- /dev/null +++ b/apps/flask/flask3-social-media/Dockerfile @@ -0,0 +1,16 @@ +FROM python:slim + +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +RUN pip install gunicorn pymysql cryptography + +COPY app app +COPY migrations migrations +COPY microblog.py config.py boot.sh ./ +RUN chmod a+x boot.sh + +ENV FLASK_APP microblog.py +RUN flask translate compile + +EXPOSE 5000 +ENTRYPOINT ["./boot.sh"] diff --git a/apps/flask/flask3-social-media/LICENSE b/apps/flask/flask3-social-media/LICENSE new file mode 100644 index 00000000..62e77cb4 --- /dev/null +++ b/apps/flask/flask3-social-media/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Miguel Grinberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/apps/flask/flask3-social-media/Procfile b/apps/flask/flask3-social-media/Procfile new file mode 100644 index 00000000..62bc8945 --- /dev/null +++ b/apps/flask/flask3-social-media/Procfile @@ -0,0 +1,2 @@ +web: flask db upgrade; flask translate compile; gunicorn microblog:app +worker: rq worker microblog-tasks diff --git a/apps/flask/flask3-social-media/README.md b/apps/flask/flask3-social-media/README.md new file mode 100644 index 00000000..00758792 --- /dev/null +++ b/apps/flask/flask3-social-media/README.md @@ -0,0 +1,155 @@ +# Microblog + +A full-featured social media microblogging platform built with Flask 3. Users can post short messages, follow other users, send private messages, and more. + +## Running the App + +### Prerequisites + +- Python 3.10+ +- SQLite (included with Python, used by default) +- Redis (optional) +- Elasticsearch (optional) + +### Installation + +1. Create and activate a virtual environment: + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Set up environment variables (create a `.env` file): + +```bash +SECRET_KEY=your-secret-key +# DATABASE_URL=postgresql://... # Optional, defaults to SQLite (app.db) +MAIL_SERVER=smtp.example.com # Optional +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME=your-email +MAIL_PASSWORD=your-password +REDIS_URL=redis://localhost:6379 # Optional +ELASTICSEARCH_URL=http://localhost:9200 # Optional +``` + +> **Note:** By default, the app uses SQLite (`app.db` in the project root). No database setup is required for local development. + +4. Initialize the database: + +```bash +flask db upgrade +``` + +5. Run the development server: + +```bash +flask run +``` + +The app will be available at `http://localhost:5000`. + +### Docker Deployment + +The app includes a `Dockerfile` and `boot.sh` script for containerized deployment using Gunicorn. + +--- + +## Application Structure + +``` +├── microblog.py # Application entry point +├── config.py # Configuration settings +├── requirements.txt # Python dependencies +├── app/ +│ ├── __init__.py # App factory and extension initialization +│ ├── models.py # SQLAlchemy database models +│ ├── search.py # Elasticsearch integration +│ ├── tasks.py # Background task definitions (RQ) +│ ├── email.py # Email sending utilities +│ ├── translate.py # Translation service integration +│ ├── cli.py # Custom CLI commands +│ ├── api/ # REST API blueprint +│ │ ├── auth.py # Token authentication +│ │ ├── users.py # User endpoints +│ │ ├── tokens.py # Token management +│ │ └── errors.py # API error handlers +│ ├── auth/ # Authentication blueprint +│ │ ├── routes.py # Login, register, password reset +│ │ ├── forms.py # WTForms definitions +│ │ └── email.py # Auth-related emails +│ ├── main/ # Main application blueprint +│ │ ├── routes.py # Homepage, user profiles, posts, messaging +│ │ └── forms.py # Post and profile forms +│ ├── errors/ # Error handling blueprint +│ │ └── handlers.py # 404, 500 error pages +│ ├── templates/ # Jinja2 templates +│ └── translations/ # i18n message catalogs (en, es) +└── migrations/ # Alembic database migrations +``` + +## Features + +### User System +- Registration with email validation +- Login/logout with Flask-Login session management +- Password reset via email (JWT tokens) +- User profiles with avatars (Gravatar) +- "About me" bios and last seen timestamps + +### Social Features +- Create short posts (140 characters) +- Follow/unfollow other users +- Timeline feed showing posts from followed users +- Private messaging between users +- Real-time notifications + +### API +- RESTful API at `/api/` with token-based authentication +- Endpoints for user CRUD, followers, and following lists +- Paginated responses with hypermedia links + +### Search +- Full-text search powered by Elasticsearch (optional) +- Automatic indexing of posts on create/update/delete + +### Background Tasks +- Redis Queue (RQ) for async job processing +- Export posts feature runs as background task + +### Internationalization +- Multi-language support via Flask-Babel +- English and Spanish translations included +- Language auto-detection from browser preferences + +## Database Models + +| Model | Description | +|-------|-------------| +| `User` | User accounts with authentication, profiles, and social relationships | +| `Post` | Short text posts with timestamps and language detection | +| `Message` | Private messages between users | +| `Notification` | Real-time notification payloads | +| `Task` | Background task tracking for RQ jobs | + +## Key Dependencies + +- **Flask** - Web framework +- **Flask-SQLAlchemy** - Database ORM (SQLite by default, PostgreSQL supported) +- **Flask-Migrate** - Database migrations (Alembic) +- **Flask-Login** - User session management +- **Flask-WTF** - Form handling and CSRF protection +- **Flask-Mail** - Email sending +- **Flask-Babel** - Internationalization +- **Flask-Moment** - Client-side timestamp formatting +- **Flask-HTTPAuth** - API authentication +- **PyJWT** - JSON Web Tokens for password reset +- **Redis / RQ** - Background task queue +- **Elasticsearch** - Full-text search diff --git a/apps/flask/flask3-social-media/Vagrantfile b/apps/flask/flask3-social-media/Vagrantfile new file mode 100644 index 00000000..b5126023 --- /dev/null +++ b/apps/flask/flask3-social-media/Vagrantfile @@ -0,0 +1,7 @@ +Vagrant.configure("2") do |config| + config.vm.box = "ubuntu/jammy64" + config.vm.network "private_network", ip: "192.168.56.10" + config.vm.provider "virtualbox" do |vb| + vb.memory = "2048" + end +end diff --git a/apps/flask/flask3-social-media/app/__init__.py b/apps/flask/flask3-social-media/app/__init__.py new file mode 100644 index 00000000..f15c103d --- /dev/null +++ b/apps/flask/flask3-social-media/app/__init__.py @@ -0,0 +1,110 @@ +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 +try: + from elasticsearch import Elasticsearch +except ImportError: + Elasticsearch = None +try: + from redis import Redis + import rq +except ImportError: + Redis = None + rq = None +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 Elasticsearch and app.config['ELASTICSEARCH_URL'] else None + if Redis and rq: + app.redis = Redis.from_url(app.config['REDIS_URL']) + app.task_queue = rq.Queue('microblog-tasks', connection=app.redis) + else: + app.redis = None + app.task_queue = 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) + + from app.api import bp as api_bp + app.register_blueprint(api_bp, url_prefix='/api') + + 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 app.config['LOG_TO_STDOUT']: + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.INFO) + app.logger.addHandler(stream_handler) + else: + 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/apps/flask/flask3-social-media/app/api/__init__.py b/apps/flask/flask3-social-media/app/api/__init__.py new file mode 100644 index 00000000..61b2e601 --- /dev/null +++ b/apps/flask/flask3-social-media/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('api', __name__) + +from app.api import users, errors, tokens diff --git a/apps/flask/flask3-social-media/app/api/auth.py b/apps/flask/flask3-social-media/app/api/auth.py new file mode 100644 index 00000000..82ab2636 --- /dev/null +++ b/apps/flask/flask3-social-media/app/api/auth.py @@ -0,0 +1,30 @@ +import sqlalchemy as sa +from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth +from app import db +from app.models import User +from app.api.errors import error_response + +basic_auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + + +@basic_auth.verify_password +def verify_password(username, password): + user = db.session.scalar(sa.select(User).where(User.username == username)) + if user and user.check_password(password): + return user + + +@basic_auth.error_handler +def basic_auth_error(status): + return error_response(status) + + +@token_auth.verify_token +def verify_token(token): + return User.check_token(token) if token else None + + +@token_auth.error_handler +def token_auth_error(status): + return error_response(status) diff --git a/apps/flask/flask3-social-media/app/api/errors.py b/apps/flask/flask3-social-media/app/api/errors.py new file mode 100644 index 00000000..ece3729b --- /dev/null +++ b/apps/flask/flask3-social-media/app/api/errors.py @@ -0,0 +1,19 @@ +from werkzeug.http import HTTP_STATUS_CODES +from werkzeug.exceptions import HTTPException +from app.api import bp + + +def error_response(status_code, message=None): + payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} + if message: + payload['message'] = message + return payload, status_code + + +def bad_request(message): + return error_response(400, message) + + +@bp.errorhandler(HTTPException) +def handle_exception(e): + return error_response(e.code) diff --git a/apps/flask/flask3-social-media/app/api/tokens.py b/apps/flask/flask3-social-media/app/api/tokens.py new file mode 100644 index 00000000..29a82a40 --- /dev/null +++ b/apps/flask/flask3-social-media/app/api/tokens.py @@ -0,0 +1,19 @@ +from app import db +from app.api import bp +from app.api.auth import basic_auth, token_auth + + +@bp.route('/tokens', methods=['POST']) +@basic_auth.login_required +def get_token(): + token = basic_auth.current_user().get_token() + db.session.commit() + return {'token': token} + + +@bp.route('/tokens', methods=['DELETE']) +@token_auth.login_required +def revoke_token(): + token_auth.current_user().revoke_token() + db.session.commit() + return '', 204 diff --git a/apps/flask/flask3-social-media/app/api/users.py b/apps/flask/flask3-social-media/app/api/users.py new file mode 100644 index 00000000..8a0b59e5 --- /dev/null +++ b/apps/flask/flask3-social-media/app/api/users.py @@ -0,0 +1,81 @@ +import sqlalchemy as sa +from flask import request, url_for, abort +from app import db +from app.models import User +from app.api import bp +from app.api.auth import token_auth +from app.api.errors import bad_request + + +@bp.route('/users/', methods=['GET']) +@token_auth.login_required +def get_user(id): + return db.get_or_404(User, id).to_dict() + + +@bp.route('/users', methods=['GET']) +@token_auth.login_required +def get_users(): + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + return User.to_collection_dict(sa.select(User), page, per_page, + 'api.get_users') + + +@bp.route('/users//followers', methods=['GET']) +@token_auth.login_required +def get_followers(id): + user = db.get_or_404(User, id) + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + return User.to_collection_dict(user.followers.select(), page, per_page, + 'api.get_followers', id=id) + + +@bp.route('/users//following', methods=['GET']) +@token_auth.login_required +def get_following(id): + user = db.get_or_404(User, id) + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + return User.to_collection_dict(user.following.select(), page, per_page, + 'api.get_following', id=id) + + +@bp.route('/users', methods=['POST']) +def create_user(): + data = request.get_json() + if 'username' not in data or 'email' not in data or 'password' not in data: + return bad_request('must include username, email and password fields') + if db.session.scalar(sa.select(User).where( + User.username == data['username'])): + return bad_request('please use a different username') + if db.session.scalar(sa.select(User).where( + User.email == data['email'])): + return bad_request('please use a different email address') + user = User() + user.from_dict(data, new_user=True) + db.session.add(user) + db.session.commit() + return user.to_dict(), 201, {'Location': url_for('api.get_user', + id=user.id)} + + +@bp.route('/users/', methods=['PUT']) +@token_auth.login_required +def update_user(id): + if token_auth.current_user().id != id: + abort(403) + user = db.get_or_404(User, id) + data = request.get_json() + if 'username' in data and data['username'] != user.username and \ + db.session.scalar(sa.select(User).where( + User.username == data['username'])): + return bad_request('please use a different username') + if 'email' in data and data['email'] != user.email and \ + db.session.scalar(sa.select(User).where( + User.email == data['email'])): + return bad_request('please use a different email address') + user.from_dict(data, new_user=False) + db.session.commit() + return user.to_dict() diff --git a/apps/flask/flask3-social-media/app/auth/__init__.py b/apps/flask/flask3-social-media/app/auth/__init__.py new file mode 100644 index 00000000..088b0336 --- /dev/null +++ b/apps/flask/flask3-social-media/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes diff --git a/apps/flask/flask3-social-media/app/auth/email.py b/apps/flask/flask3-social-media/app/auth/email.py new file mode 100644 index 00000000..98755ace --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/auth/forms.py b/apps/flask/flask3-social-media/app/auth/forms.py new file mode 100644 index 00000000..d8a4bad1 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/auth/routes.py b/apps/flask/flask3-social-media/app/auth/routes.py new file mode 100644 index 00000000..79360ff7 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/cli.py b/apps/flask/flask3-social-media/app/cli.py new file mode 100644 index 00000000..25e2a275 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/email.py b/apps/flask/flask3-social-media/app/email.py new file mode 100644 index 00000000..2375cead --- /dev/null +++ b/apps/flask/flask3-social-media/app/email.py @@ -0,0 +1,24 @@ +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, + attachments=None, sync=False): + msg = Message(subject, sender=sender, recipients=recipients) + msg.body = text_body + msg.html = html_body + if attachments: + for attachment in attachments: + msg.attach(*attachment) + if sync: + mail.send(msg) + else: + Thread(target=send_async_email, + args=(current_app._get_current_object(), msg)).start() diff --git a/apps/flask/flask3-social-media/app/errors/__init__.py b/apps/flask/flask3-social-media/app/errors/__init__.py new file mode 100644 index 00000000..5701c1d1 --- /dev/null +++ b/apps/flask/flask3-social-media/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from app.errors import handlers diff --git a/apps/flask/flask3-social-media/app/errors/handlers.py b/apps/flask/flask3-social-media/app/errors/handlers.py new file mode 100644 index 00000000..62d42ad8 --- /dev/null +++ b/apps/flask/flask3-social-media/app/errors/handlers.py @@ -0,0 +1,24 @@ +from flask import render_template, request +from app import db +from app.errors import bp +from app.api.errors import error_response as api_error_response + + +def wants_json_response(): + return request.accept_mimetypes['application/json'] >= \ + request.accept_mimetypes['text/html'] + + +@bp.app_errorhandler(404) +def not_found_error(error): + if wants_json_response(): + return api_error_response(404) + return render_template('errors/404.html'), 404 + + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + if wants_json_response(): + return api_error_response(500) + return render_template('errors/500.html'), 500 diff --git a/apps/flask/flask3-social-media/app/main/__init__.py b/apps/flask/flask3-social-media/app/main/__init__.py new file mode 100644 index 00000000..3b580b07 --- /dev/null +++ b/apps/flask/flask3-social-media/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from app.main import routes diff --git a/apps/flask/flask3-social-media/app/main/forms.py b/apps/flask/flask3-social-media/app/main/forms.py new file mode 100644 index 00000000..132a299d --- /dev/null +++ b/apps/flask/flask3-social-media/app/main/forms.py @@ -0,0 +1,53 @@ +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 == 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) + + +class MessageForm(FlaskForm): + message = TextAreaField(_l('Message'), validators=[ + DataRequired(), Length(min=1, max=140)]) + submit = SubmitField(_l('Submit')) diff --git a/apps/flask/flask3-social-media/app/main/routes.py b/apps/flask/flask3-social-media/app/main/routes.py new file mode 100644 index 00000000..103bbc7b --- /dev/null +++ b/apps/flask/flask3-social-media/app/main/routes.py @@ -0,0 +1,241 @@ +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, \ + MessageForm +from app.models import User, Post, Message, Notification +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('/user//popup') +@login_required +def user_popup(username): + user = db.first_or_404(sa.select(User).where(User.username == username)) + form = EmptyForm() + return render_template('user_popup.html', user=user, 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) + + +@bp.route('/send_message/', methods=['GET', 'POST']) +@login_required +def send_message(recipient): + user = db.first_or_404(sa.select(User).where(User.username == recipient)) + form = MessageForm() + if form.validate_on_submit(): + msg = Message(author=current_user, recipient=user, + body=form.message.data) + db.session.add(msg) + user.add_notification('unread_message_count', + user.unread_message_count()) + db.session.commit() + flash(_('Your message has been sent.')) + return redirect(url_for('main.user', username=recipient)) + return render_template('send_message.html', title=_('Send Message'), + form=form, recipient=recipient) + + +@bp.route('/messages') +@login_required +def messages(): + current_user.last_message_read_time = datetime.now(timezone.utc) + current_user.add_notification('unread_message_count', 0) + db.session.commit() + page = request.args.get('page', 1, type=int) + query = current_user.messages_received.select().order_by( + Message.timestamp.desc()) + messages = db.paginate(query, page=page, + per_page=current_app.config['POSTS_PER_PAGE'], + error_out=False) + next_url = url_for('main.messages', page=messages.next_num) \ + if messages.has_next else None + prev_url = url_for('main.messages', page=messages.prev_num) \ + if messages.has_prev else None + return render_template('messages.html', messages=messages.items, + next_url=next_url, prev_url=prev_url) + + +@bp.route('/export_posts') +@login_required +def export_posts(): + if current_user.get_task_in_progress('export_posts'): + flash(_('An export task is currently in progress')) + else: + current_user.launch_task('export_posts', _('Exporting posts...')) + db.session.commit() + return redirect(url_for('main.user', username=current_user.username)) + + +@bp.route('/notifications') +@login_required +def notifications(): + since = request.args.get('since', 0.0, type=float) + query = current_user.notifications.select().where( + Notification.timestamp > since).order_by(Notification.timestamp.asc()) + notifications = db.session.scalars(query) + return [{ + 'name': n.name, + 'data': n.get_data(), + 'timestamp': n.timestamp + } for n in notifications] diff --git a/apps/flask/flask3-social-media/app/models.py b/apps/flask/flask3-social-media/app/models.py new file mode 100644 index 00000000..db447d89 --- /dev/null +++ b/apps/flask/flask3-social-media/app/models.py @@ -0,0 +1,356 @@ +from datetime import datetime, timezone, timedelta +from hashlib import md5 +import json +import secrets +from time import time +from typing import Optional +import sqlalchemy as sa +import sqlalchemy.orm as so +from flask import current_app, url_for +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +import jwt +import redis +import rq +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) + + +class PaginatedAPIMixin(object): + @staticmethod + def to_collection_dict(query, page, per_page, endpoint, **kwargs): + resources = db.paginate(query, page=page, per_page=per_page, + error_out=False) + data = { + 'items': [item.to_dict() for item in resources.items], + '_meta': { + 'page': page, + 'per_page': per_page, + 'total_pages': resources.pages, + 'total_items': resources.total + }, + '_links': { + 'self': url_for(endpoint, page=page, per_page=per_page, + **kwargs), + 'next': url_for(endpoint, page=page + 1, per_page=per_page, + **kwargs) if resources.has_next else None, + 'prev': url_for(endpoint, page=page - 1, per_page=per_page, + **kwargs) if resources.has_prev else None + } + } + return data + + +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(PaginatedAPIMixin, 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)) + last_message_read_time: so.Mapped[Optional[datetime]] + token: so.Mapped[Optional[str]] = so.mapped_column( + sa.String(32), index=True, unique=True) + token_expiration: so.Mapped[Optional[datetime]] + + 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') + messages_sent: so.WriteOnlyMapped['Message'] = so.relationship( + foreign_keys='Message.sender_id', back_populates='author') + messages_received: so.WriteOnlyMapped['Message'] = so.relationship( + foreign_keys='Message.recipient_id', back_populates='recipient') + notifications: so.WriteOnlyMapped['Notification'] = so.relationship( + back_populates='user') + tasks: so.WriteOnlyMapped['Task'] = so.relationship(back_populates='user') + + 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) + + def unread_message_count(self): + last_read_time = self.last_message_read_time or datetime(1900, 1, 1) + query = sa.select(Message).where(Message.recipient == self, + Message.timestamp > last_read_time) + return db.session.scalar(sa.select(sa.func.count()).select_from( + query.subquery())) + + def add_notification(self, name, data): + db.session.execute(self.notifications.delete().where( + Notification.name == name)) + n = Notification(name=name, payload_json=json.dumps(data), user=self) + db.session.add(n) + return n + + def launch_task(self, name, description, *args, **kwargs): + rq_job = current_app.task_queue.enqueue(f'app.tasks.{name}', self.id, + *args, **kwargs) + task = Task(id=rq_job.get_id(), name=name, description=description, + user=self) + db.session.add(task) + return task + + def get_tasks_in_progress(self): + query = self.tasks.select().where(Task.complete == False) + return db.session.scalars(query) + + def get_task_in_progress(self, name): + query = self.tasks.select().where(Task.name == name, + Task.complete == False) + return db.session.scalar(query) + + def posts_count(self): + query = sa.select(sa.func.count()).select_from( + self.posts.select().subquery()) + return db.session.scalar(query) + + def to_dict(self, include_email=False): + data = { + 'id': self.id, + 'username': self.username, + 'last_seen': self.last_seen.replace( + tzinfo=timezone.utc).isoformat(), + 'about_me': self.about_me, + 'post_count': self.posts_count(), + 'follower_count': self.followers_count(), + 'following_count': self.following_count(), + '_links': { + 'self': url_for('api.get_user', id=self.id), + 'followers': url_for('api.get_followers', id=self.id), + 'following': url_for('api.get_following', id=self.id), + 'avatar': self.avatar(128) + } + } + if include_email: + data['email'] = self.email + return data + + def from_dict(self, data, new_user=False): + for field in ['username', 'email', 'about_me']: + if field in data: + setattr(self, field, data[field]) + if new_user and 'password' in data: + self.set_password(data['password']) + + def get_token(self, expires_in=3600): + now = datetime.now(timezone.utc) + if self.token and self.token_expiration.replace( + tzinfo=timezone.utc) > now + timedelta(seconds=60): + return self.token + self.token = secrets.token_hex(16) + self.token_expiration = now + timedelta(seconds=expires_in) + db.session.add(self) + return self.token + + def revoke_token(self): + self.token_expiration = datetime.now(timezone.utc) - timedelta( + seconds=1) + + @staticmethod + def check_token(token): + user = db.session.scalar(sa.select(User).where(User.token == token)) + if user is None or user.token_expiration.replace( + tzinfo=timezone.utc) < datetime.now(timezone.utc): + return None + return user + + +@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) + + +class Message(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + sender_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + recipient_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + body: so.Mapped[str] = so.mapped_column(sa.String(140)) + timestamp: so.Mapped[datetime] = so.mapped_column( + index=True, default=lambda: datetime.now(timezone.utc)) + + author: so.Mapped[User] = so.relationship( + foreign_keys='Message.sender_id', + back_populates='messages_sent') + recipient: so.Mapped[User] = so.relationship( + foreign_keys='Message.recipient_id', + back_populates='messages_received') + + def __repr__(self): + return ''.format(self.body) + + +class Notification(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), + index=True) + timestamp: so.Mapped[float] = so.mapped_column(index=True, default=time) + payload_json: so.Mapped[str] = so.mapped_column(sa.Text) + + user: so.Mapped[User] = so.relationship(back_populates='notifications') + + def get_data(self): + return json.loads(str(self.payload_json)) + + +class Task(db.Model): + id: so.Mapped[str] = so.mapped_column(sa.String(36), primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(128), index=True) + description: so.Mapped[Optional[str]] = so.mapped_column(sa.String(128)) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id)) + complete: so.Mapped[bool] = so.mapped_column(default=False) + + user: so.Mapped[User] = so.relationship(back_populates='tasks') + + def get_rq_job(self): + try: + rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) + except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): + return None + return rq_job + + def get_progress(self): + job = self.get_rq_job() + return job.meta.get('progress', 0) if job is not None else 100 diff --git a/apps/flask/flask3-social-media/app/search.py b/apps/flask/flask3-social-media/app/search.py new file mode 100644 index 00000000..51c5c960 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/static/loading.gif b/apps/flask/flask3-social-media/app/static/loading.gif new file mode 100644 index 00000000..d0bce154 Binary files /dev/null and b/apps/flask/flask3-social-media/app/static/loading.gif differ diff --git a/apps/flask/flask3-social-media/app/tasks.py b/apps/flask/flask3-social-media/app/tasks.py new file mode 100644 index 00000000..356079e7 --- /dev/null +++ b/apps/flask/flask3-social-media/app/tasks.py @@ -0,0 +1,56 @@ +import json +import sys +import time +import sqlalchemy as sa +from flask import render_template +from rq import get_current_job +from app import create_app, db +from app.models import User, Post, Task +from app.email import send_email + +app = create_app() +app.app_context().push() + + +def _set_task_progress(progress): + job = get_current_job() + if job: + job.meta['progress'] = progress + job.save_meta() + task = db.session.get(Task, job.get_id()) + task.user.add_notification('task_progress', {'task_id': job.get_id(), + 'progress': progress}) + if progress >= 100: + task.complete = True + db.session.commit() + + +def export_posts(user_id): + try: + user = db.session.get(User, user_id) + _set_task_progress(0) + data = [] + i = 0 + total_posts = db.session.scalar(sa.select(sa.func.count()).select_from( + user.posts.select().subquery())) + for post in db.session.scalars(user.posts.select().order_by( + Post.timestamp.asc())): + data.append({'body': post.body, + 'timestamp': post.timestamp.isoformat() + 'Z'}) + time.sleep(5) + i += 1 + _set_task_progress(100 * i // total_posts) + + send_email( + '[Microblog] Your blog posts', + sender=app.config['ADMINS'][0], recipients=[user.email], + text_body=render_template('email/export_posts.txt', user=user), + html_body=render_template('email/export_posts.html', user=user), + attachments=[('posts.json', 'application/json', + json.dumps({'posts': data}, indent=4))], + sync=True) + except Exception: + _set_task_progress(100) + app.logger.error('Unhandled exception', exc_info=sys.exc_info()) + finally: + _set_task_progress(100) diff --git a/apps/flask/flask3-social-media/app/templates/_post.html b/apps/flask/flask3-social-media/app/templates/_post.html new file mode 100644 index 00000000..2a1c697e --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/auth/login.html b/apps/flask/flask3-social-media/app/templates/auth/login.html new file mode 100644 index 00000000..4fe56690 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/auth/register.html b/apps/flask/flask3-social-media/app/templates/auth/register.html new file mode 100644 index 00000000..d93124d3 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/auth/reset_password.html b/apps/flask/flask3-social-media/app/templates/auth/reset_password.html new file mode 100644 index 00000000..15123f9c --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/auth/reset_password_request.html b/apps/flask/flask3-social-media/app/templates/auth/reset_password_request.html new file mode 100644 index 00000000..a0f066d9 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/base.html b/apps/flask/flask3-social-media/app/templates/base.html new file mode 100644 index 00000000..4eca8da6 --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/base.html @@ -0,0 +1,181 @@ + + + + + + {% if title %} + {{ title }} - Microblog + {% else %} + {{ _('Welcome to Microblog') }} + {% endif %} + + + + +
+ {% if current_user.is_authenticated %} + {% with tasks = current_user.get_tasks_in_progress() %} + {% if tasks %} + {% for task in tasks %} + + {% endfor %} + {% endif %} + {% endwith %} + {% 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/apps/flask/flask3-social-media/app/templates/bootstrap_wtf.html b/apps/flask/flask3-social-media/app/templates/bootstrap_wtf.html new file mode 100644 index 00000000..65c0db6b --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/edit_profile.html b/apps/flask/flask3-social-media/app/templates/edit_profile.html new file mode 100644 index 00000000..f067d8f3 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/email/export_posts.html b/apps/flask/flask3-social-media/app/templates/email/export_posts.html new file mode 100644 index 00000000..f98383a8 --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/email/export_posts.html @@ -0,0 +1,4 @@ +

Dear {{ user.username }},

+

Please find attached the archive of your posts that you requested.

+

Sincerely,

+

The Microblog Team

diff --git a/apps/flask/flask3-social-media/app/templates/email/export_posts.txt b/apps/flask/flask3-social-media/app/templates/email/export_posts.txt new file mode 100644 index 00000000..81c9f7af --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/email/export_posts.txt @@ -0,0 +1,7 @@ +Dear {{ user.username }}, + +Please find attached the archive of your posts that you requested. + +Sincerely, + +The Microblog Team diff --git a/apps/flask/flask3-social-media/app/templates/email/reset_password.html b/apps/flask/flask3-social-media/app/templates/email/reset_password.html new file mode 100644 index 00000000..bca2f0f7 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/email/reset_password.txt b/apps/flask/flask3-social-media/app/templates/email/reset_password.txt new file mode 100644 index 00000000..bd107b5c --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/errors/404.html b/apps/flask/flask3-social-media/app/templates/errors/404.html new file mode 100644 index 00000000..3cc4cf1d --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/errors/404.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block content %} +

{{ _('Not Found') }}

+

{{ _('Back') }}

+{% endblock %} diff --git a/apps/flask/flask3-social-media/app/templates/errors/500.html b/apps/flask/flask3-social-media/app/templates/errors/500.html new file mode 100644 index 00000000..f494a21d --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/index.html b/apps/flask/flask3-social-media/app/templates/index.html new file mode 100644 index 00000000..3dec86c3 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/messages.html b/apps/flask/flask3-social-media/app/templates/messages.html new file mode 100644 index 00000000..fe09eaee --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/messages.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +

{{ _('Messages') }}

+ {% for post in messages %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/apps/flask/flask3-social-media/app/templates/search.html b/apps/flask/flask3-social-media/app/templates/search.html new file mode 100644 index 00000000..db9978e1 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/templates/send_message.html b/apps/flask/flask3-social-media/app/templates/send_message.html new file mode 100644 index 00000000..8e8d77ec --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/send_message.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% import "bootstrap_wtf.html" as wtf %} + +{% block content %} +

{{ _('Send Message to %(recipient)s', recipient=recipient) }}

+ {{ wtf.quick_form(form) }} +{% endblock %} diff --git a/apps/flask/flask3-social-media/app/templates/user.html b/apps/flask/flask3-social-media/app/templates/user.html new file mode 100644 index 00000000..c973426a --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/user.html @@ -0,0 +1,57 @@ +{% 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') }}

+ {% if not current_user.get_task_in_progress('export_posts') %} +

{{ _('Export your posts') }}

+ {% endif %} + {% 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 %} + {% if user != current_user %} +

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

+ {% endif %} +
+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} diff --git a/apps/flask/flask3-social-media/app/templates/user_popup.html b/apps/flask/flask3-social-media/app/templates/user_popup.html new file mode 100644 index 00000000..52a386f7 --- /dev/null +++ b/apps/flask/flask3-social-media/app/templates/user_popup.html @@ -0,0 +1,27 @@ +
+ +

{{ user.username }}

+ {% if user.about_me %}

{{ user.about_me }}

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

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

+ {% endif %} +

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

+ {% if user != current_user %} + {% if not current_user.is_following(user) %} +

+

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

+ {% else %} +

+

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

+ {% endif %} + {% endif %} +
diff --git a/apps/flask/flask3-social-media/app/translate.py b/apps/flask/flask3-social-media/app/translate.py new file mode 100644 index 00000000..b51a17a8 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/app/translations/es/LC_MESSAGES/messages.po b/apps/flask/flask3-social-media/app/translations/es/LC_MESSAGES/messages.po new file mode 100644 index 00000000..ad0e8ed7 --- /dev/null +++ b/apps/flask/flask3-social-media/app/translations/es/LC_MESSAGES/messages.po @@ -0,0 +1,320 @@ +# 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:27-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:20 +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 app/main/forms.py:44 +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/forms.py:43 +msgid "Message" +msgstr "Mensaje" + +#: app/main/routes.py:36 +msgid "Your post is now live!" +msgstr "¡Tu artículo ha sido publicado!" + +#: app/main/routes.py:94 +msgid "Your changes have been saved." +msgstr "Tus cambios han sido salvados." + +#: app/main/routes.py:99 app/templates/edit_profile.html:5 +msgid "Edit Profile" +msgstr "Editar Perfil" + +#: app/main/routes.py:108 app/main/routes.py:124 +#, python-format +msgid "User %(username)s not found." +msgstr "El usuario %(username)s no ha sido encontrado." + +#: app/main/routes.py:111 +msgid "You cannot follow yourself!" +msgstr "¡No te puedes seguir a tí mismo!" + +#: app/main/routes.py:115 +#, python-format +msgid "You are following %(username)s!" +msgstr "¡Ahora estás siguiendo a %(username)s!" + +#: app/main/routes.py:127 +msgid "You cannot unfollow yourself!" +msgstr "¡No te puedes dejar de seguir a tí mismo!" + +#: app/main/routes.py:131 +#, python-format +msgid "You are not following %(username)s." +msgstr "No estás siguiendo a %(username)s." + +#: app/main/routes.py:170 +msgid "Your message has been sent." +msgstr "Tu mensaje ha sido enviado." + +#: app/main/routes.py:172 +msgid "Send Message" +msgstr "Enviar Mensaje" + +#: app/main/routes.py:197 +msgid "An export task is currently in progress" +msgstr "Una tarea de exportación esta en progreso" + +#: app/main/routes.py:199 +msgid "Exporting posts..." +msgstr "Exportando artículos..." + +#: app/templates/_post.html:16 +#, python-format +msgid "%(username)s said %(when)s" +msgstr "%(username)s dijo %(when)s" + +#: app/templates/_post.html:27 +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:36 app/templates/messages.html:4 +msgid "Messages" +msgstr "Mensajes" + +#: app/templates/base.html:45 +msgid "Profile" +msgstr "Perfil" + +#: app/templates/base.html:46 +msgid "Logout" +msgstr "Salir" + +#: app/templates/base.html:95 +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:37 +msgid "Newer posts" +msgstr "Artículos siguientes" + +#: app/templates/index.html:22 app/templates/user.html:42 +msgid "Older posts" +msgstr "Artículos previos" + +#: app/templates/messages.html:12 +msgid "Newer messages" +msgstr "Mensajes siguientes" + +#: app/templates/messages.html:17 +msgid "Older messages" +msgstr "Mensajes previos" + +#: app/templates/search.html:4 +msgid "Search Results" +msgstr "" + +#: app/templates/search.html:12 +msgid "Previous results" +msgstr "" + +#: app/templates/search.html:17 +msgid "Next results" +msgstr "" + +#: app/templates/send_message.html:5 +#, python-format +msgid "Send Message to %(recipient)s" +msgstr "Enviar Mensaje a %(recipient)s" + +#: app/templates/user.html:8 +msgid "User" +msgstr "Usuario" + +#: app/templates/user.html:11 app/templates/user_popup.html:9 +msgid "Last seen on" +msgstr "Última visita" + +#: app/templates/user.html:13 app/templates/user_popup.html:11 +#, python-format +msgid "%(count)d followers" +msgstr "%(count)d seguidores" + +#: app/templates/user.html:13 app/templates/user_popup.html:11 +#, 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 "Export your posts" +msgstr "Exportar tus artículos" + +#: app/templates/user.html:20 app/templates/user_popup.html:14 +msgid "Follow" +msgstr "Seguir" + +#: app/templates/user.html:22 app/templates/user_popup.html:16 +msgid "Unfollow" +msgstr "Dejar de seguir" + +#: app/templates/user.html:25 +msgid "Send private message" +msgstr "Enviar mensaje privado" + +#: 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/apps/flask/flask3-social-media/babel.cfg b/apps/flask/flask3-social-media/babel.cfg new file mode 100644 index 00000000..b42c3d57 --- /dev/null +++ b/apps/flask/flask3-social-media/babel.cfg @@ -0,0 +1,2 @@ +[python: app/**.py] +[jinja2: app/templates/**.html] diff --git a/apps/flask/flask3-social-media/boot.sh b/apps/flask/flask3-social-media/boot.sh new file mode 100755 index 00000000..2f0992d2 --- /dev/null +++ b/apps/flask/flask3-social-media/boot.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# this script is used to boot a Docker container +while true; do + flask db upgrade + if [[ "$?" == "0" ]]; then + break + fi + echo Deploy command failed, retrying in 5 secs... + sleep 5 +done +exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app diff --git a/apps/flask/flask3-social-media/config.py b/apps/flask/flask3-social-media/config.py new file mode 100644 index 00000000..7e50c9ee --- /dev/null +++ b/apps/flask/flask3-social-media/config.py @@ -0,0 +1,25 @@ +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' + SERVER_NAME = os.environ.get('SERVER_NAME') + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace( + 'postgres://', 'postgresql://') or \ + 'sqlite:///' + os.path.join(basedir, 'app.db') + LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') + 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') + REDIS_URL = os.environ.get('REDIS_URL') or 'redis://' + POSTS_PER_PAGE = 25 diff --git a/apps/flask/flask3-social-media/deployment/nginx/microblog b/apps/flask/flask3-social-media/deployment/nginx/microblog new file mode 100644 index 00000000..784d9972 --- /dev/null +++ b/apps/flask/flask3-social-media/deployment/nginx/microblog @@ -0,0 +1,37 @@ +server { + # listen on port 80 (http) + listen 80; + server_name _; + location / { + # redirect any requests to the same URL but on https + return 301 https://$host$request_uri; + } +} +server { + # listen on port 443 (https) + listen 443 ssl; + server_name _; + + # location of the self-signed SSL certificate + ssl_certificate /home/ubuntu/microblog/certs/cert.pem; + ssl_certificate_key /home/ubuntu/microblog/certs/key.pem; + + # write access and error logs to /var/log + access_log /var/log/microblog_access.log; + error_log /var/log/microblog_error.log; + + location / { + # forward application requests to the gunicorn server + proxy_pass http://localhost:8000; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /static { + # handle static files directly, without forwarding to the application + alias /home/ubuntu/microblog/app/static; + expires 30d; + } +} diff --git a/apps/flask/flask3-social-media/deployment/supervisor/microblog-tasks.conf b/apps/flask/flask3-social-media/deployment/supervisor/microblog-tasks.conf new file mode 100644 index 00000000..d47b6dac --- /dev/null +++ b/apps/flask/flask3-social-media/deployment/supervisor/microblog-tasks.conf @@ -0,0 +1,9 @@ +[program:microblog-tasks] +command=/home/ubuntu/microblog/venv/bin/rq worker microblog-tasks +numprocs=1 +directory=/home/ubuntu/microblog +user=ubuntu +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true diff --git a/apps/flask/flask3-social-media/deployment/supervisor/microblog.conf b/apps/flask/flask3-social-media/deployment/supervisor/microblog.conf new file mode 100644 index 00000000..134f18ba --- /dev/null +++ b/apps/flask/flask3-social-media/deployment/supervisor/microblog.conf @@ -0,0 +1,8 @@ +[program:microblog] +command=/home/ubuntu/microblog/venv/bin/gunicorn -b localhost:8000 -w 4 microblog:app +directory=/home/ubuntu/microblog +user=ubuntu +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true diff --git a/apps/flask/flask3-social-media/microblog.py b/apps/flask/flask3-social-media/microblog.py new file mode 100644 index 00000000..458e9327 --- /dev/null +++ b/apps/flask/flask3-social-media/microblog.py @@ -0,0 +1,12 @@ +import sqlalchemy as sa +import sqlalchemy.orm as so +from app import create_app, db +from app.models import User, Post, Message, Notification, Task + +app = create_app() + + +@app.shell_context_processor +def make_shell_context(): + return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post, + 'Message': Message, 'Notification': Notification, 'Task': Task} diff --git a/apps/flask/flask3-social-media/migrations/README b/apps/flask/flask3-social-media/migrations/README new file mode 100755 index 00000000..98e4f9c4 --- /dev/null +++ b/apps/flask/flask3-social-media/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/apps/flask/flask3-social-media/migrations/alembic.ini b/apps/flask/flask3-social-media/migrations/alembic.ini new file mode 100644 index 00000000..f8ed4801 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/env.py b/apps/flask/flask3-social-media/migrations/env.py new file mode 100755 index 00000000..23663ff2 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/script.py.mako b/apps/flask/flask3-social-media/migrations/script.py.mako new file mode 100755 index 00000000..2c015630 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/versions/2b017edaa91f_add_language_to_posts.py b/apps/flask/flask3-social-media/migrations/versions/2b017edaa91f_add_language_to_posts.py new file mode 100644 index 00000000..260cbcb8 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/versions/37f06a334dbf_new_fields_in_user_model.py b/apps/flask/flask3-social-media/migrations/versions/37f06a334dbf_new_fields_in_user_model.py new file mode 100644 index 00000000..d633a5b9 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/versions/780739b227a7_posts_table.py b/apps/flask/flask3-social-media/migrations/versions/780739b227a7_posts_table.py new file mode 100644 index 00000000..219178ad --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/versions/834b1a697901_user_tokens.py b/apps/flask/flask3-social-media/migrations/versions/834b1a697901_user_tokens.py new file mode 100644 index 00000000..4508a0be --- /dev/null +++ b/apps/flask/flask3-social-media/migrations/versions/834b1a697901_user_tokens.py @@ -0,0 +1,32 @@ +"""user tokens + +Revision ID: 834b1a697901 +Revises: c81bac34faab +Create Date: 2017-11-05 18:41:07.996137 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '834b1a697901' +down_revision = 'c81bac34faab' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('token', sa.String(length=32), nullable=True)) + op.add_column('user', sa.Column('token_expiration', sa.DateTime(), nullable=True)) + op.create_index(op.f('ix_user_token'), 'user', ['token'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_token'), table_name='user') + op.drop_column('user', 'token_expiration') + op.drop_column('user', 'token') + # ### end Alembic commands ### diff --git a/apps/flask/flask3-social-media/migrations/versions/ae346256b650_followers.py b/apps/flask/flask3-social-media/migrations/versions/ae346256b650_followers.py new file mode 100644 index 00000000..f41f3c55 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/versions/c81bac34faab_tasks.py b/apps/flask/flask3-social-media/migrations/versions/c81bac34faab_tasks.py new file mode 100644 index 00000000..3b96cf7d --- /dev/null +++ b/apps/flask/flask3-social-media/migrations/versions/c81bac34faab_tasks.py @@ -0,0 +1,38 @@ +"""tasks + +Revision ID: c81bac34faab +Revises: f7ac3d27bb1d +Create Date: 2017-11-23 10:56:49.599779 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c81bac34faab' +down_revision = 'f7ac3d27bb1d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('task', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('description', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('complete', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_name'), 'task', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_task_name'), table_name='task') + op.drop_table('task') + # ### end Alembic commands ### diff --git a/apps/flask/flask3-social-media/migrations/versions/d049de007ccf_private_messages.py b/apps/flask/flask3-social-media/migrations/versions/d049de007ccf_private_messages.py new file mode 100644 index 00000000..f0a081ff --- /dev/null +++ b/apps/flask/flask3-social-media/migrations/versions/d049de007ccf_private_messages.py @@ -0,0 +1,53 @@ +"""private messages + +Revision ID: d049de007ccf +Revises: 834b1a697901 +Create Date: 2017-11-12 23:30:28.571784 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd049de007ccf' +down_revision = '2b017edaa91f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('message', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('recipient_id', sa.Integer(), nullable=False), + sa.Column('body', sa.String(length=140), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + with op.batch_alter_table('message', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_message_recipient_id'), ['recipient_id'], unique=False) + batch_op.create_index(batch_op.f('ix_message_sender_id'), ['sender_id'], unique=False) + batch_op.create_index(batch_op.f('ix_message_timestamp'), ['timestamp'], unique=False) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('last_message_read_time', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('last_message_read_time') + + with op.batch_alter_table('message', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_message_timestamp')) + batch_op.drop_index(batch_op.f('ix_message_sender_id')) + batch_op.drop_index(batch_op.f('ix_message_recipient_id')) + + op.drop_table('message') + # ### end Alembic commands ### diff --git a/apps/flask/flask3-social-media/migrations/versions/e517276bb1c2_users_table.py b/apps/flask/flask3-social-media/migrations/versions/e517276bb1c2_users_table.py new file mode 100644 index 00000000..bfd12ed7 --- /dev/null +++ b/apps/flask/flask3-social-media/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/apps/flask/flask3-social-media/migrations/versions/f7ac3d27bb1d_notifications.py b/apps/flask/flask3-social-media/migrations/versions/f7ac3d27bb1d_notifications.py new file mode 100644 index 00000000..a7dd4890 --- /dev/null +++ b/apps/flask/flask3-social-media/migrations/versions/f7ac3d27bb1d_notifications.py @@ -0,0 +1,46 @@ +"""notifications + +Revision ID: f7ac3d27bb1d +Revises: d049de007ccf +Create Date: 2017-11-22 19:48:39.945858 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f7ac3d27bb1d' +down_revision = 'd049de007ccf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('notification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.Float(), nullable=False), + sa.Column('payload_json', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + with op.batch_alter_table('notification', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_notification_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_notification_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_notification_user_id'), ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('notification', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_notification_user_id')) + batch_op.drop_index(batch_op.f('ix_notification_timestamp')) + batch_op.drop_index(batch_op.f('ix_notification_name')) + + op.drop_table('notification') + # ### end Alembic commands ### diff --git a/apps/flask/flask3-social-media/requirements.txt b/apps/flask/flask3-social-media/requirements.txt new file mode 100644 index 00000000..0d986eff --- /dev/null +++ b/apps/flask/flask3-social-media/requirements.txt @@ -0,0 +1,22 @@ +alembic +Babel +email-validator +Flask>=3.0 +flask-babel +Flask-HTTPAuth +Flask-Login +Flask-Mail +Flask-Migrate +Flask-Moment +Flask-SQLAlchemy +Flask-WTF +gunicorn +langdetect +PyJWT +python-dotenv +redis +requests +rq +SQLAlchemy +Werkzeug +WTForms diff --git a/apps/flask/flask3-social-media/tests.py b/apps/flask/flask3-social-media/tests.py new file mode 100755 index 00000000..4ef90985 --- /dev/null +++ b/apps/flask/flask3-social-media/tests.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +from datetime import datetime, timezone, timedelta +import unittest +from app import create_app, db +from app.models import User, Post +from config import Config + + +class TestConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite://' + ELASTICSEARCH_URL = None + + +class UserModelCase(unittest.TestCase): + def setUp(self): + self.app = create_app(TestConfig) + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_password_hashing(self): + u = User(username='susan', email='susan@example.com') + u.set_password('cat') + self.assertFalse(u.check_password('dog')) + self.assertTrue(u.check_password('cat')) + + def test_avatar(self): + u = User(username='john', email='john@example.com') + self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/' + 'd4c74594d841139328695756648b6bd6' + '?d=identicon&s=128')) + + def test_follow(self): + u1 = User(username='john', email='john@example.com') + u2 = User(username='susan', email='susan@example.com') + db.session.add(u1) + db.session.add(u2) + db.session.commit() + following = db.session.scalars(u1.following.select()).all() + followers = db.session.scalars(u2.followers.select()).all() + self.assertEqual(following, []) + self.assertEqual(followers, []) + + u1.follow(u2) + db.session.commit() + self.assertTrue(u1.is_following(u2)) + self.assertEqual(u1.following_count(), 1) + self.assertEqual(u2.followers_count(), 1) + u1_following = db.session.scalars(u1.following.select()).all() + u2_followers = db.session.scalars(u2.followers.select()).all() + self.assertEqual(u1_following[0].username, 'susan') + self.assertEqual(u2_followers[0].username, 'john') + + u1.unfollow(u2) + db.session.commit() + self.assertFalse(u1.is_following(u2)) + self.assertEqual(u1.following_count(), 0) + self.assertEqual(u2.followers_count(), 0) + + def test_follow_posts(self): + # create four users + u1 = User(username='john', email='john@example.com') + u2 = User(username='susan', email='susan@example.com') + u3 = User(username='mary', email='mary@example.com') + u4 = User(username='david', email='david@example.com') + db.session.add_all([u1, u2, u3, u4]) + + # create four posts + now = datetime.now(timezone.utc) + p1 = Post(body="post from john", author=u1, + timestamp=now + timedelta(seconds=1)) + p2 = Post(body="post from susan", author=u2, + timestamp=now + timedelta(seconds=4)) + p3 = Post(body="post from mary", author=u3, + timestamp=now + timedelta(seconds=3)) + p4 = Post(body="post from david", author=u4, + timestamp=now + timedelta(seconds=2)) + db.session.add_all([p1, p2, p3, p4]) + db.session.commit() + + # setup the followers + u1.follow(u2) # john follows susan + u1.follow(u4) # john follows david + u2.follow(u3) # susan follows mary + u3.follow(u4) # mary follows david + db.session.commit() + + # check the following posts of each user + f1 = db.session.scalars(u1.following_posts()).all() + f2 = db.session.scalars(u2.following_posts()).all() + f3 = db.session.scalars(u3.following_posts()).all() + f4 = db.session.scalars(u4.following_posts()).all() + self.assertEqual(f1, [p2, p4, p1]) + self.assertEqual(f2, [p2, p3]) + self.assertEqual(f3, [p3, p4]) + self.assertEqual(f4, [p4]) + + +if __name__ == '__main__': + unittest.main(verbosity=2)