user profiles and settings

This commit is contained in:
rimu 2023-10-07 21:32:19 +13:00
parent 43957873de
commit 8a18573974
21 changed files with 391 additions and 45 deletions

View file

@ -62,6 +62,9 @@ def create_app(config_class=Config):
from app.community import bp as community_bp from app.community import bp as community_bp
app.register_blueprint(community_bp, url_prefix='/community') app.register_blueprint(community_bp, url_prefix='/community')
from app.user import bp as user_bp
app.register_blueprint(user_bp)
def get_resource_as_string(name, charset='utf-8'): def get_resource_as_string(name, charset='utf-8'):
with app.open_resource(name) as f: with app.open_resource(name) as f:
return f.read().decode(charset) return f.read().decode(charset)

View file

@ -1,6 +1,5 @@
import markdown2 import markdown2
import werkzeug.exceptions import werkzeug.exceptions
from sqlalchemy import text
from app import db from app import db
from app.activitypub import bp from app.activitypub import bp
@ -8,6 +7,7 @@ from flask import request, Response, current_app, abort, jsonify, json
from app.activitypub.signature import HttpSignature from app.activitypub.signature import HttpSignature
from app.community.routes import show_community from app.community.routes import show_community
from app.user.routes import show_profile
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE
from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \ from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \
PostReply, Instance, PostVote, PostReplyVote, File PostReply, Instance, PostVote, PostReplyVote, File
@ -106,7 +106,7 @@ def user_profile(actor):
actor = actor.strip() actor = actor.strip()
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first() user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
if user is not None: if user is not None:
if 'application/ld+json' in request.headers.get('Accept', '') or request.accept_mimetypes.accept_json: if 'application/ld+json' in request.headers.get('Accept', ''):
server = current_app.config['SERVER_NAME'] server = current_app.config['SERVER_NAME']
actor_data = { "@context": default_context(), actor_data = { "@context": default_context(),
"type": "Person", "type": "Person",
@ -122,18 +122,24 @@ def user_profile(actor):
"endpoints": { "endpoints": {
"sharedInbox": f"https://{server}/inbox" "sharedInbox": f"https://{server}/inbox"
}, },
"published": user.created.isoformat() "published": user.created.isoformat(),
} }
if user.avatar_id is not None: if user.avatar_id is not None:
actor_data["icon"] = { actor_data["icon"] = {
"type": "Image", "type": "Image",
"url": f"https://{server}/avatars/{user.avatar.file_path}" "url": f"https://{server}/avatars/{user.avatar.file_path}"
} }
if user.about:
actor_data['source'] = {
"content": user.about,
"mediaType": "text/markdown"
}
actor_data['summary'] = allowlist_html(markdown2.markdown(user.about, safe_mode=True))
resp = jsonify(actor_data) resp = jsonify(actor_data)
resp.content_type = 'application/activity+json' resp.content_type = 'application/activity+json'
return resp return resp
else: else:
return render_template('user_profile.html', user=user) return show_profile(user)
@bp.route('/c/<actor>', methods=['GET']) @bp.route('/c/<actor>', methods=['GET'])

View file

@ -79,8 +79,13 @@ def show_community(community: Community):
mod_user_ids = [mod.user_id for mod in mods] mod_user_ids = [mod.user_id for mod in mods]
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if current_user.ignore_bots:
posts = community.posts.query.filter(Post.from_bot == False).all()
else:
posts = community.posts
return render_template('community/community.html', community=community, title=community.title, return render_template('community/community.html', community=community, title=community.title,
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=community.posts) is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts)
@bp.route('/<actor>/subscribe', methods=['GET']) @bp.route('/<actor>/subscribe', methods=['GET'])

View file

@ -25,21 +25,21 @@ def index():
def list_communities(): def list_communities():
search_param = request.args.get('search', '') search_param = request.args.get('search', '')
if search_param == '': if search_param == '':
communities = Community.query.all() communities = Community.query.filter_by(banned=False).all()
else: else:
query = search(select(Community), search_param, sort=True) query = search(select(Community), search_param, sort=True)
communities = db.session.scalars(query).all() communities = db.session.scalars(query).all()
return render_template('list_communities.html', communities=communities, search=search_param) return render_template('list_communities.html', communities=communities, search=search_param, title=_('Communities'))
@bp.route('/communities/local', methods=['GET']) @bp.route('/communities/local', methods=['GET'])
def list_local_communities(): def list_local_communities():
communities = Community.query.filter_by(ap_id=None).all() communities = Community.query.filter_by(ap_id=None, banned=False).all()
return render_template('list_communities.html', communities=communities) return render_template('list_communities.html', communities=communities, title=_('Local communities'))
@bp.route('/communities/subscribed', methods=['GET']) @bp.route('/communities/subscribed', methods=['GET'])
def list_subscribed_communities(): def list_subscribed_communities():
communities = Community.query.join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all() communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all()
return render_template('list_communities.html', communities=communities) return render_template('list_communities.html', communities=communities, title=_('Subscribed communities'))

View file

@ -142,6 +142,8 @@ class User(UserMixin, db.Model):
stripe_subscription_id = db.Column(db.String(50)) stripe_subscription_id = db.Column(db.String(50))
searchable = db.Column(db.Boolean, default=True) searchable = db.Column(db.Boolean, default=True)
indexable = db.Column(db.Boolean, default=False) indexable = db.Column(db.Boolean, default=False)
bot = db.Column(db.Boolean, default=False)
ignore_bots = db.Column(db.Boolean, default=False)
avatar = db.relationship('File', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan") avatar = db.relationship('File', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan")
cover = db.relationship('File', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan") cover = db.relationship('File', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan")
@ -180,6 +182,22 @@ class User(UserMixin, db.Model):
return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(
digest, size) digest, size)
def avatar_image(self) -> str:
if self.avatar_id is not None:
if self.avatar.file_path is not None:
return self.avatar.file_path
if self.avatar.source_url is not None:
return self.avatar.source_url
return ''
def cover_image(self) -> str:
if self.cover_id is not None:
if self.cover.file_path is not None:
return self.cover.file_path
if self.cover.source_url is not None:
return self.cover.source_url
return ''
def get_reset_password_token(self, expires_in=600): def get_reset_password_token(self, expires_in=600):
return jwt.encode( return jwt.encode(
{'reset_password': self.id, 'exp': time() + expires_in}, {'reset_password': self.id, 'exp': time() + expires_in},
@ -264,6 +282,7 @@ class Post(db.Model):
nsfl = db.Column(db.Boolean, default=False) nsfl = db.Column(db.Boolean, default=False)
sticky = db.Column(db.Boolean, default=False) sticky = db.Column(db.Boolean, default=False)
indexable = db.Column(db.Boolean, default=False) indexable = db.Column(db.Boolean, default=False)
from_bot = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the content arrived here created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the content arrived here
posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the original server created it posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the original server created it
last_active = db.Column(db.DateTime, index=True, default=datetime.utcnow) last_active = db.Column(db.DateTime, index=True, default=datetime.utcnow)
@ -305,6 +324,7 @@ class PostReply(db.Model):
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
ip = db.Column(db.String(50)) ip = db.Column(db.String(50))
from_bot = db.Column(db.Boolean, default=False)
up_votes = db.Column(db.Integer, default=0) up_votes = db.Column(db.Integer, default=0)
down_votes = db.Column(db.Integer, default=0) down_votes = db.Column(db.Integer, default=0)
ranking = db.Column(db.Integer, default=0) ranking = db.Column(db.Integer, default=0)

View file

@ -144,8 +144,10 @@ a.no-underline {
} }
} }
$small_text: 87%;
.small, small { .small, small {
font-size: 87%; font-size: $small_text;
} }
fieldset legend { fieldset legend {

View file

@ -325,7 +325,7 @@ fieldset legend {
font-size: 120%; font-size: 120%;
margin-top: 8px; margin-top: 8px;
} }
.post_list .post_teaser h3 a { .post_list .post_teaser .meta_row a, .post_list .post_teaser .main_row a, .post_list .post_teaser .utilities_row a {
text-decoration: none; text-decoration: none;
} }

View file

@ -117,7 +117,9 @@ nav, etc which are used site-wide */
h3 { h3 {
font-size: 120%; font-size: 120%;
margin-top: 8px; margin-top: 8px;
}
.meta_row, .main_row, .utilities_row {
a { a {
text-decoration: none; text-decoration: none;
} }

View file

@ -52,6 +52,7 @@
{% else %} {% else %}
<li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li> <li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/communities">{{ _('Communities') }}</a></li> <li class="nav-item"><a class="nav-link" href="/communities">{{ _('Communities') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/u/{{ current_user.user_name }}">{{ current_user.user_name }}</a></li>
<li class="nav-item"><a class="nav-link" href="/auth/logout">{{ _('Log out') }}</a></li> <li class="nav-item"><a class="nav-link" href="/auth/logout">{{ _('Log out') }}</a></li>
{% endif %} {% endif %}
</ul> </ul>

View file

@ -0,0 +1,3 @@
<div class="post_reply_teaser">
{{ post_reply.body_html }}
</div>

View file

@ -0,0 +1,31 @@
<div class="post_teaser">
<div class="row meta_row small">
<div class="col"><a href="{{ url_for('activitypub.user_profile', actor=post.author.user_name) }}">{{ post.author.user_name }}</a> · {{ moment(post.posted_at).fromNow() }}</div>
</div>
<div class="row main_row">
<div class="col{% if post.image_id %}-8{% endif %}">
<h3>
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}</a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</h3>
</div>
{% if post.image_id %}
<div class="col-4">
<img src="{{ post.image.source_url}}" alt="{{ post.image.alt_text }}"
width="100" />
</div>
{% endif %}
</div>
<div class="row utilities_row">
<div class="col-4">
up {{ post.score }} down
</div>
<div class="col-6">
<a href="{{ url_for('community.show_post', post_id=post.id, _anchor='replies') }}">{{post.reply_count}}</a> additional tools
</div>
<div class="col-2">...</div>
</div>
</div>

View file

@ -37,37 +37,7 @@
{% endif %} {% endif %}
<div class="post_list"> <div class="post_list">
{% for post in posts %} {% for post in posts %}
<div class="post_teaser"> {% include 'community/_post_teaser.html' %}
<div class="row">
<div class="col">{{ post.author.user_name }} · {{ moment(post.posted_at).fromNow() }}</div>
</div>
<div class="row">
<div class="col{% if post.image_id %}-8{% endif %}">
<h3>
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}</a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</h3>
</div>
{% if post.image_id %}
<div class="col-4">
<img src="{{ post.image.source_url}}" alt="{{ post.image.alt_text }}"
width="100" />
</div>
{% endif %}
</div>
<div class="row">
<div class="col-4">
up {{ post.score }} down
</div>
<div class="col-6">
<a href="{{ url_for('community.show_post', post_id=post.id, _anchor='replies') }}">{{post.reply_count}}</a> additional tools
</div>
<div class="col-2">...</div>
</div>
</div>
{% else %} {% else %}
<p>{{ _('No posts in this community yet.') }}</p> <p>{{ _('No posts in this community yet.') }}</p>
{% endfor %} {% endfor %}

View file

@ -52,7 +52,9 @@
{% endif %} {% endif %}
</small></p> </small></p>
{% endif %} {% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}</small></p> <p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
<a href="{{ url_for('activitypub.user_profile', actor=post.author.user_name) }}">{{ post.author.user_name }}</a>
</p>
{% endif %} {% endif %}
</div> </div>

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.user_name }}</a></li>
<li class="breadcrumb-item active">{{ _('Edit profile') }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ _('Edit profile') }}</h1>
<form method='post'>
{{ render_form(form) }}
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.user_name }}</a></li>
<li class="breadcrumb-item active">{{ _('Change settings') }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ _('Change settings') }}</h1>
<form method='post'>
{{ render_form(form) }}
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-8 position-relative">
{% if user.cover_image() != '' %}
<div class="community_header" style="height: 240px; background-image: url({{ user.cover_image() }});">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/users">{{ _('People') }}</a></li>
<li class="breadcrumb-item active">{{ user.user_name|shorten }}</li>
</ol>
</nav>
</div>
<img class="community_icon_big bump_up rounded-circle" src="{{ user.avatar_image() }}" />
<h1 class="mt-2">{{ user.user_name }}</h1>
{% elif user.avatar_image() != '' %}
<div class="row">
<div class="col-2">
<img class="community_icon_big rounded-circle" src="{{ user.avatar_image() }}" />
</div>
<div class="col-10">
<h1 class="mt-3">{{ user.user_name }}</h1>
</div>
</div>
{% else %}
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/users">{{ _('People') }}</a></li>
<li class="breadcrumb-item active">{{ user.user_name|shorten }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ user.user_name }}</h1>
{{ user.about_html|safe }}
{% endif %}
{% if len(posts) > 0 %}
<h2 class="mt-4">Posts</h2>
<div class="post_list">
{% for post in posts %}
{% include 'community/_post_teaser.html' %}
{% endfor %}
</div>
{% endif %}
{% if len(post_replies) > 0 %}
<h2 class="mt-4">Comments</h2>
<div class="post_list">
{% for post_reply in post_replies %}
{% include 'community/_post_reply_teaser.html' %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="col-4">
{% if current_user.id == user.id %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Manage') }}</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/profile">{{ _('Profile') }}</a>
</div>
<div class="col-6">
<a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/settings">{{ _('Settings') }}</a>
</div>
</div>
</div>
</div>
{% endif %}
{% if len(moderates) > 0 %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Moderates') }}</h2>
</div>
<div class="card-body">
<ol>
{% for community in moderates %}
<li>
<a href="/c/{{ community.link() }}">
<img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}
</a>
</li>
{% endfor %}
</ol>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

5
app/user/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('user', __name__)
from app.user import routes

30
app/user/forms.py Normal file
View file

@ -0,0 +1,30 @@
from flask import session
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, PasswordField, BooleanField, EmailField, TextAreaField, FileField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l
class ProfileForm(FlaskForm):
email = EmailField(_l('Email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)])
password_field = PasswordField(_l('Set new password'), validators=[Optional(), Length(min=1, max=50)],
render_kw={"autocomplete": 'Off'})
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)])
submit = SubmitField(_l('Save profile'))
def validate_email(self, field):
if current_user.another_account_using_email(field.data):
raise ValidationError(_l('That email address is already in use by another account'))
class SettingsForm(FlaskForm):
newsletter = BooleanField(_l('Subscribe to email newsletter'))
bot = BooleanField(_l('This profile is a bot'))
ignore_bots = BooleanField(_l('Hide posts by bots'))
nsfw = BooleanField(_l('Show NSFW posts'))
nsfl = BooleanField(_l('Show NSFL posts'))
searchable = BooleanField(_l('Show profile in fediverse searches'))
indexable = BooleanField(_l('Allow search engines to index this profile'))
manually_approves_followers = BooleanField(_l('Manually approve followers'))
submit = SubmitField(_l('Save settings'))

82
app/user/routes.py Normal file
View file

@ -0,0 +1,82 @@
import markdown2
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _
from app import db
from app.models import Post, Community, CommunityMember, User, PostReply
from app.user import bp
from app.user.forms import ProfileForm, SettingsForm
from app.utils import get_setting, render_template, allowlist_html
from sqlalchemy import desc, or_
def show_profile(user):
posts = Post.query.filter_by(user_id=user.id).order_by(desc(Post.posted_at)).all()
moderates = Community.query.filter_by(banned=False).join(CommunityMember).filter(or_(CommunityMember.is_moderator, CommunityMember.is_owner))
if user.id != current_user.id:
moderates = moderates.filter(Community.private_mods == False)
post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all()
canonical = user.ap_public_url if user.ap_public_url else None
user.about_html = allowlist_html(markdown2.markdown(user.about, safe_mode=True))
return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies,
moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s',
user_name=user.user_name))
@bp.route('/u/<actor>/profile', methods=['GET', 'POST'])
def edit_profile(actor):
actor = actor.strip()
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
if user is None:
abort(404)
form = ProfileForm()
if form.validate_on_submit():
current_user.email = form.email.data
if form.password_field.data.strip() != '':
current_user.set_password(form.password_field.data)
current_user.about = form.about.data
db.session.commit()
flash(_('Your changes have been saved.'), 'success')
return redirect(url_for('user.edit_profile', actor=actor))
elif request.method == 'GET':
form.email.data = current_user.email
form.about.data = current_user.about
form.password_field.data = ''
return render_template('user/edit_profile.html', title=_('Edit profile'), form=form, user=current_user)
@bp.route('/u/<actor>/settings', methods=['GET', 'POST'])
def change_settings(actor):
actor = actor.strip()
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
if user is None:
abort(404)
form = SettingsForm()
if form.validate_on_submit():
current_user.newsletter = form.newsletter.data
current_user.bot = form.bot.data
current_user.ignore_bots = form.ignore_bots.data
current_user.show_nsfw = form.nsfw.data
current_user.show_nsfl = form.nsfl.data
current_user.searchable = form.searchable.data
current_user.indexable = form.indexable.data
current_user.ap_manually_approves_followers = form.manually_approves_followers.data
db.session.commit()
flash(_('Your changes have been saved.'), 'success')
return redirect(url_for('user.change_settings', actor=actor))
elif request.method == 'GET':
form.newsletter.data = current_user.newsletter
form.bot.data = current_user.bot
form.ignore_bots.data = current_user.ignore_bots
form.nsfw.data = current_user.show_nsfw
form.nsfl.data = current_user.show_nsfl
form.searchable.data = current_user.searchable
form.indexable.data = current_user.indexable
form.manually_approves_followers.data = current_user.ap_manually_approves_followers
return render_template('user/edit_settings.html', title=_('Edit profile'), form=form, user=current_user)

View file

@ -0,0 +1,46 @@
"""bots
Revision ID: 8c5cc19e0670
Revises: b1d3fc38b1f7
Create Date: 2023-10-07 18:13:32.600126
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8c5cc19e0670'
down_revision = 'b1d3fc38b1f7'
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('from_bot', sa.Boolean(), nullable=True))
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.add_column(sa.Column('from_bot', sa.Boolean(), nullable=True))
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('bot', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('ignore_bots', sa.Boolean(), 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('ignore_bots')
batch_op.drop_column('bot')
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.drop_column('from_bot')
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_column('from_bot')
# ### end Alembic commands ###