report posts and communities. also block domains and instances

This commit is contained in:
rimu 2023-12-13 21:04:11 +13:00
parent ac8a229475
commit 46900390a5
17 changed files with 393 additions and 18 deletions

View file

@ -3,7 +3,7 @@ from wtforms import StringField, SubmitField, TextAreaField, BooleanField, Hidde
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l
from app.utils import domain_from_url
from app.utils import domain_from_url, MultiCheckboxField
class AddLocalCommunity(FlaskForm):
@ -83,3 +83,17 @@ class CreatePostForm(FlaskForm):
return True
class ReportCommunityForm(FlaskForm):
reason_choices = [('1', _l('Breaks instance rules')),
('2', _l('Abandoned by moderators')),
('3', _l('Cult')),
('4', _l('Scam')),
('5', _l('Alt-right pipeline')),
('6', _l('Hate / genocide')),
('7', _l('Other')),
]
reasons = MultiCheckboxField(_l('Reason'), choices=reason_choices)
description = StringField(_l('More info'))
report_remote = BooleanField('Also send report to originating instance')
submit = SubmitField(_l('Report'))

View file

@ -1,24 +1,22 @@
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 pillow_heif import register_heif_opener
from sqlalchemy import or_, desc
from app import db, constants, cache
from app.activitypub.signature import RsaKeys, HttpSignature
from app.activitypub.util import default_context
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \
ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote, utcnow
File, PostVote, utcnow, Report, Notification, InstanceBlock
from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304
import os
from feedgen.feed import FeedGenerator
from datetime import timezone
@ -38,7 +36,7 @@ def add_local():
community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data,
rules=form.rules.data, nsfw=form.nsfw.data, private_key=private_key,
public_key=public_key,
ap_profile_id=current_app.config['SERVER_NAME'] + '/c/' + form.url.data,
ap_profile_id='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data,
subscriptions_count=1)
icon_file = request.files['icon_file']
if icon_file and icon_file.filename != '':
@ -377,4 +375,43 @@ def add_post(actor):
images_disabled=images_disabled)
@login_required
@bp.route('/community/<int:community_id>/report', methods=['GET', 'POST'])
def community_report(community_id: int):
community = Community.query.get_or_404(community_id)
form = ReportCommunityForm()
if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=1, reporter_id=current_user.id, suspect_community_id=community.id)
db.session.add(report)
# Notify admin
# todo: find all instance admin(s). for now just load User.id == 1
admins = [User.query.get_or_404(1)]
for admin in admins:
notification = Notification(user_id=admin.id, title=_('A post has been reported'),
url=community.local_url(),
author_id=current_user.id)
db.session.add(notification)
db.session.commit()
# todo: federate report to originating instance
if not community.is_local() and form.report_remote.data:
...
flash(_('Community has been reported, thank you!'))
return redirect(community.local_url())
return render_template('community/community_report.html', title=_('Report community'), form=form, community=community)
@login_required
@bp.route('/community/<int:community_id>/block_instance', methods=['GET', 'POST'])
def community_block_instance(community_id: int):
community = Community.query.get_or_404(community_id)
existing = InstanceBlock.query.filter_by(user_id=current_user.id, instance_id=community.instance_id).first()
if not existing:
db.session.add(InstanceBlock(user_id=current_user.id, instance_id=community.instance_id))
db.session.commit()
flash(_('Content from %(name)s will be hidden.', name=community.instance.domain))
return redirect(community.local_url())

View file

@ -70,11 +70,14 @@ class Community(db.Model):
title = db.Column(db.String(256))
description = db.Column(db.Text)
rules = db.Column(db.Text)
content_warning = db.Column(db.Text) # "Are you sure you want to view this community?"
subscriptions_count = db.Column(db.Integer, default=0)
post_count = db.Column(db.Integer, default=0)
post_reply_count = db.Column(db.Integer, default=0)
nsfw = db.Column(db.Boolean, default=False)
nsfl = db.Column(db.Boolean, default=False)
instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), index=True)
low_quality = db.Column(db.Boolean, default=False) # upvotes earned in low quality communities don't improve reputation
created_at = db.Column(db.DateTime, default=utcnow)
last_active = db.Column(db.DateTime, default=utcnow)
public_key = db.Column(db.Text)
@ -94,6 +97,7 @@ class Community(db.Model):
banned = db.Column(db.Boolean, default=False)
restricted_to_mods = db.Column(db.Boolean, default=False)
new_mods_wanted = db.Column(db.Boolean, default=False)
searchable = db.Column(db.Boolean, default=True)
private_mods = db.Column(db.Boolean, default=False)
@ -175,6 +179,12 @@ class Community(db.Model):
def is_local(self):
return self.ap_id is None or self.profile_id().startswith('https://' + current_app.config['SERVER_NAME'])
def local_url(self):
if self.is_local():
return self.ap_profile_id
else:
return f"https://{current_app.config['SERVER_NAME']}/c/{self.ap_id}"
user_role = db.Table('user_role',
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
@ -207,6 +217,7 @@ class User(UserMixin, db.Model):
bounces = db.Column(db.SmallInteger, default=0)
timezone = db.Column(db.String(20))
reputation = db.Column(db.Float, default=0.0)
attitude = db.Column(db.Float, default=1.0) # (upvotes cast - downvotes cast) / (upvotes + downvotes). A number between 1 and -1 is the ratio between up and down votes they cast
stripe_customer_id = db.Column(db.String(50))
stripe_subscription_id = db.Column(db.String(50))
searchable = db.Column(db.Boolean, default=True)
@ -410,6 +421,7 @@ class Post(db.Model):
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), index=True)
image_id = db.Column(db.Integer, db.ForeignKey('file.id'), index=True)
domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'), index=True)
instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), index=True)
slug = db.Column(db.String(255))
title = db.Column(db.String(255))
url = db.Column(db.String(2048))
@ -623,6 +635,15 @@ class Instance(db.Model):
created_at = db.Column(db.DateTime, default=utcnow)
updated_at = db.Column(db.DateTime, default=utcnow)
posts = db.relationship('Post', backref='instance', lazy='dynamic')
communities = db.relationship('Community', backref='instance', lazy='dynamic')
class InstanceBlock(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), primary_key=True)
created_at = db.Column(db.DateTime, default=utcnow)
class Settings(db.Model):
name = db.Column(db.String(50), primary_key=True)
@ -716,6 +737,20 @@ class Notification(db.Model):
created_at = db.Column(db.DateTime, default=utcnow)
class Report(db.Model):
id = db.Column(db.Integer, primary_key=True)
reasons = db.Column(db.String(256))
description = db.Column(db.String(256))
status = db.Column(db.Integer, default=0)
type = db.Column(db.Integer, default=0) # 0 = user, 1 = post, 2 = reply, 3 = community
reporter_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_community_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
suspect_reply_id = db.Column(db.Integer, db.ForeignKey('post_reply.id'))
created_at = db.Column(db.DateTime, default=utcnow)
updated = db.Column(db.DateTime, default=utcnow)
@login.user_loader
def load_user(id):
return User.query.get(int(id))

View file

@ -1,10 +1,37 @@
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField, BooleanField
from wtforms import TextAreaField, SubmitField, BooleanField, StringField
from wtforms.validators import DataRequired, Length
from flask_babel import _, lazy_gettext as _l
from app.utils import MultiCheckboxField
class NewReplyForm(FlaskForm):
body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 3}, validators={DataRequired(), Length(min=3, max=5000)})
notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Comment'))
class ReportPostForm(FlaskForm):
reason_choices = [('1', _l('Breaks community rules')), ('7', _l('Spam')), ('2', _l('Harassment')),
('3', _l('Threatening violence')), ('4', _l('Hate / genocide')),
('6', _l('Sharing personal information')),
('5', _l('Minor abuse or sexualization')),
('8', _l('Non-consensual intimate media')),
('9', _l('Prohibited transaction')), ('10', _l('Impersonation')),
('11', _l('Copyright violation')), ('12', _l('Trademark violation')),
('13', _l('Self-harm or suicide')),
('14', _l('Other'))]
reasons = MultiCheckboxField(_l('Reason'), choices=reason_choices)
description = StringField(_l('More info'))
report_remote = BooleanField('Also send report to originating instance')
submit = SubmitField(_l('Report'))
def reasons_to_string(self, reason_data) -> str:
result = []
for reason_id in reason_data:
for choice in self.reason_choices:
if choice[0] == reason_id:
result.append(str(choice[1]))
return ', '.join(result)

View file

@ -9,12 +9,12 @@ from app import db, constants
from app.activitypub.signature import HttpSignature
from app.activitypub.util import default_context
from app.community.util import save_post
from app.post.forms import NewReplyForm
from app.post.forms import NewReplyForm, ReportPostForm
from app.community.forms import CreatePostForm
from app.post.util import post_replies, get_comment_branch, post_reply_count
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification, utcnow
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report
from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
@ -407,12 +407,73 @@ def post_delete(post_id: int):
post.flush_cache()
db.session.delete(post)
db.session.commit()
flash('Post deleted.')
flash(_('Post deleted.'))
return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name))
@login_required
@bp.route('/post/<int:post_id>/report', methods=['GET', 'POST'])
def post_report(post_id: int):
post = Post.query.get_or_404(post_id)
form = ReportPostForm()
if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=1, reporter_id=current_user.id, suspect_post_id=post.id)
db.session.add(report)
# Notify moderators
for mod in post.community.moderators():
notification = Notification(user_id=mod.user_id, title=_('A post has been reported'),
url=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
author_id=current_user.id)
db.session.add(notification)
# todo: Also notify admins for certain types of report
db.session.commit()
# todo: federate report to originating instance
if not post.community.is_local() and form.report_remote.data:
...
flash(_('Post has been reported, thank you!'))
return redirect(post.community.local_url())
return render_template('post/post_report.html', title=_('Report post'), form=form, post=post)
@login_required
@bp.route('/post/<int:post_id>/block_user', methods=['GET', 'POST'])
def post_block_user(post_id: int):
post = Post.query.get_or_404(post_id)
existing = UserBlock.query.filter_by(blocker_id=current_user.id, blocked_id=post.author.id).first()
if not existing:
db.session.add(UserBlock(blocker_id=current_user.id, blocked_id=post.author.id))
db.session.commit()
flash(_('%(name)s has been blocked.', name=post.author.user_name))
# todo: federate block to post author instance
return redirect(post.community.local_url())
@login_required
@bp.route('/post/<int:post_id>/block_domain', methods=['GET', 'POST'])
def post_block_domain(post_id: int):
post = Post.query.get_or_404(post_id)
existing = DomainBlock.query.filter_by(user_id=current_user.id, domain_id=post.domain_id).first()
if not existing:
db.session.add(DomainBlock(user_id=current_user.id, domain_id=post.domain_id))
db.session.commit()
flash(_('Posts linking to %(name)s will be hidden.', name=post.domain.name))
return redirect(post.community.local_url())
@login_required
@bp.route('/post/<int:post_id>/block_instance', methods=['GET', 'POST'])
def post_block_instance(post_id: int):
post = Post.query.get_or_404(post_id)
existing = InstanceBlock.query.filter_by(user_id=current_user.id, instance_id=post.instance_id).first()
if not existing:
db.session.add(InstanceBlock(user_id=current_user.id, instance_id=post.instance_id))
db.session.commit()
flash(_('Content from %(name)s will be hidden.', name=post.instance.domain))
return redirect(post.community.local_url())

View file

@ -178,6 +178,10 @@
content: "\ea03";
}
.fe-block::before {
content: "\ea04";
}
.fe-report::before {
content: "\e967";
}

View file

@ -181,6 +181,10 @@ nav, etc which are used site-wide */
content: "\ea03";
}
.fe-block::before {
content: "\ea04";
}
.fe-report::before {
content: "\e967";
}
@ -579,6 +583,14 @@ fieldset legend {
padding-left: 3px;
}
#reasons {
border: none;
list-style-type: none;
padding: 0;
overflow-y: auto;
height: 135px;
}
.table tr th {
vertical-align: middle;
}

View file

@ -325,6 +325,14 @@ nav, etc which are used site-wide */
}
}
#reasons {
border: none;
list-style-type: none;
padding: 0;
overflow-y: auto;
height: 135px;
}
.table {
tr th {
vertical-align: middle;

View file

@ -180,6 +180,10 @@
content: "\ea03";
}
.fe-block::before {
content: "\ea04";
}
.fe-report::before {
content: "\e967";
}
@ -480,6 +484,15 @@ nav.navbar {
margin-bottom: 10px;
}
.post_options_link {
display: block;
position: absolute;
bottom: 0;
right: -2px;
width: 41px;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #777;

View file

@ -197,6 +197,14 @@ nav.navbar {
margin-bottom: 10px;
}
.post_options_link {
display: block;
position: absolute;
bottom: 0;
right: -2px;
width: 41px;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
body {
background-color: $dark-grey;

View file

@ -1,4 +1,4 @@
<div class="row">
<div class="row position-relative">
{% if post.type == POST_TYPE_IMAGE %}
<div class="col post_type_image">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
@ -72,6 +72,7 @@
</div>
{% endif %}
<a href="{{ url_for('post.post_options', post_id=post.id) }}" class="post_options_link" rel="nofollow"><span class="fe fe-options" title="Options"> </span></a>
</div>
{% if post.body_html %}

View file

@ -31,7 +31,7 @@
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}"><span class="fe fe-reply"></span></a>
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}">{{ post.reply_count }}</a>
</div>
<div class="col-2"><a href="{{ url_for('post.post_options', post_id=post.id) }}"><span class="fe fe-options" title="Options"> </span></a></div>
<div class="col-2"><a href="{{ url_for('post.post_options', post_id=post.id) }}" rel="nofollow"><span class="fe fe-options" title="Options"> </span></a></div>
</div>
</div>
<div class="col col-md-2">

View file

@ -9,11 +9,25 @@
<div class="card-title">{{ _('Options for "%(post_title)s"', post_title=post.title) }}</div>
<ul class="option_list">
{% if current_user.is_authenticated and (post.user_id == current_user.id or post.community.is_moderator()) %}
<li><a href="{{ url_for('post.post_edit', post_id=post.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-edit"></span> Edit</a></li>
<li><a href="{{ url_for('post.post_delete', post_id=post.id) }}" class="no-underline confirm_first" rel="nofollow"><span class="fe fe-delete"></span> Delete</a></li>
<li><a href="{{ url_for('post.post_edit', post_id=post.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-edit"></span>
{{ _('Edit') }}</a></li>
<li><a href="{{ url_for('post.post_delete', post_id=post.id) }}" class="no-underline confirm_first" rel="nofollow"><span class="fe fe-delete"></span>
{{ _('Delete') }}</a></li>
{% endif %}
{% if post.user_id != current_user.id %}
<li><a href="{{ url_for('post.post_report', post_id=post.id) }}" class="no-underline"><span class="fe fe-report"></span> Report</a></li>
<li><a href="{{ url_for('post.post_block_user', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _('Block post author @%(author_name)s', author_name=post.author.user_name) }}</a></li>
{% if post.domain_id %}
<li><a href="{{ url_for('post.post_block_domain', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _('Block domain %(domain)s', domain=post.domain.name) }}</a></li>
{% endif %}
{% if post.instance_id %}
<li><a href="{{ url_for('post.post_block_instance', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _('Hide every post from %(name)s', name=post.instance.domain) }}</a></li>
{% endif %}
<li><a href="{{ url_for('post.post_report', post_id=post.id) }}" class="no-underline"><span class="fe fe-report"></span>
{{ _('Report to moderators') }}</a></li>
{% endif %}
</ul>
</div>

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Report "%(post_title)s"', post_title=post.title) }}</div>
<div class="card-body">
{{ render_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -14,7 +14,8 @@ import imghdr
from flask import current_app, json, redirect, url_for, request, make_response, Response
from flask_login import current_user
from sqlalchemy import text
from wtforms.fields import SelectField, SelectMultipleField
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache
from app.models import Settings, Domain, Instance, BannedInstances, User, Community
@ -293,3 +294,8 @@ def back(default_url):
# format a datetime in a way that is used in ActivityPub
def ap_datetime(date_time: datetime) -> str:
return date_time.isoformat() + '+00:00'
class MultiCheckboxField(SelectMultipleField):
widget = ListWidget(prefix_label=False)
option_widget = CheckboxInput()

View file

@ -0,0 +1,82 @@
"""report_block
Revision ID: 31dfc1d1d3f6
Revises: b36dac7696d1
Create Date: 2023-12-13 19:11:27.447598
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '31dfc1d1d3f6'
down_revision = 'b36dac7696d1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('instance_block',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('instance_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['instance_id'], ['instance.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'instance_id')
)
op.create_table('report',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('reasons', sa.String(length=256), nullable=True),
sa.Column('description', sa.String(length=256), nullable=True),
sa.Column('status', sa.Integer(), nullable=True),
sa.Column('type', sa.Integer(), nullable=True),
sa.Column('reporter_id', sa.Integer(), nullable=True),
sa.Column('suspect_community_id', sa.Integer(), nullable=True),
sa.Column('suspect_user_id', sa.Integer(), nullable=True),
sa.Column('suspect_post_id', sa.Integer(), nullable=True),
sa.Column('suspect_reply_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['reporter_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['suspect_community_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['suspect_post_id'], ['post.id'], ),
sa.ForeignKeyConstraint(['suspect_reply_id'], ['post_reply.id'], ),
sa.ForeignKeyConstraint(['suspect_user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.add_column(sa.Column('content_warning', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('low_quality', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('new_mods_wanted', sa.Boolean(), nullable=True))
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.add_column(sa.Column('instance_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_post_instance_id'), ['instance_id'], unique=False)
batch_op.create_foreign_key(None, 'instance', ['instance_id'], ['id'])
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('attitude', sa.Float(), 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('attitude')
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_post_instance_id'))
batch_op.drop_column('instance_id')
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.drop_column('new_mods_wanted')
batch_op.drop_column('low_quality')
batch_op.drop_column('content_warning')
op.drop_table('report')
op.drop_table('instance_block')
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
"""community instance
Revision ID: 5fb8f21295da
Revises: 31dfc1d1d3f6
Create Date: 2023-12-13 20:57:09.647260
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5fb8f21295da'
down_revision = '31dfc1d1d3f6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.add_column(sa.Column('instance_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_community_instance_id'), ['instance_id'], unique=False)
batch_op.create_foreign_key(None, 'instance', ['instance_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_community_instance_id'))
batch_op.drop_column('instance_id')
# ### end Alembic commands ###