community

This commit is contained in:
rimu 2023-09-05 20:25:02 +12:00
parent e0e8ccd6fd
commit c10e46c2e8
7 changed files with 185 additions and 19 deletions

View file

@ -1,15 +1,16 @@
from datetime import date, datetime, timedelta
from flask import render_template, redirect, url_for, flash, request, make_response, session, Markup, current_app
from flask import render_template, redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user
from flask_babel import _
from app import db
from app.activitypub.signature import RsaKeys
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity
from app.community.util import search_for_community
from app.constants import SUBSCRIPTION_MEMBER
from app.community.util import search_for_community, community_url_exists
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER
from app.models import User, Community, CommunityMember
from app.community import bp
from app.utils import get_setting
from sqlalchemy import or_
@bp.route('/add_local', methods=['GET', 'POST'])
@ -18,7 +19,10 @@ def add_local():
if get_setting('allow_nsfw', False) is False:
form.nsfw.render_kw = {'disabled': True}
if form.validate_on_submit():
if form.validate_on_submit() and not community_url_exists(form.url.data):
# todo: more intense data validation
if form.url.data.trim().lower().startswith('/c/'):
form.url.data = form.url.data[3:]
private_key, public_key = RsaKeys.generate_keypair()
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,
@ -59,4 +63,73 @@ def add_remote():
# @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird.
def show_community(community: Community):
return render_template('community/community.html', community=community, title=community.title)
mods = CommunityMember.query.filter((CommunityMember.community_id == community.id) &
(or_(
CommunityMember.is_owner,
CommunityMember.is_moderator
))
).all()
is_moderator = any(mod.user_id == current_user.id for mod in mods)
is_owner = any(mod.user_id == current_user.id and mod.is_owner == True for mod in mods)
if community.private_mods:
mod_list = []
else:
mod_user_ids = [mod.user_id for mod in mods]
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
return render_template('community/community.html', community=community, title=community.title,
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list)
@bp.route('/<actor>/subscribe', methods=['GET'])
def subscribe(actor):
actor = actor.strip()
if '@' in actor:
community = Community.query.filter_by(banned=False, ap_id=actor).first()
else:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
if not current_user.subscribed(community):
membership = CommunityMember(user_id=current_user.id, community_id=community.id)
db.session.add(membership)
db.session.commit()
flash('You have subscribed to ' + community.title)
referrer = request.headers.get('Referer', None)
if referrer is not None:
return redirect(referrer)
else:
return redirect('/c/' + actor)
else:
abort(404)
@bp.route('/<actor>/unsubscribe', methods=['GET'])
def unsubscribe(actor):
actor = actor.strip()
if '@' in actor:
community = Community.query.filter_by(banned=False, ap_id=actor).first()
else:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
subscription = current_user.subscribed(community)
if subscription:
if subscription != SUBSCRIPTION_OWNER:
db.session.query(CommunityMember).filter_by(user_id=current_user.id, community_id=community.id).delete()
db.session.commit()
flash('You are unsubscribed from ' + community.title)
else:
# todo: community deletion
flash('You need to make someone else the owner before unsubscribing.', 'warning')
# send them back where they came from
referrer = request.headers.get('Referer', None)
if referrer is not None:
return redirect(referrer)
else:
return redirect('/c/' + actor)
else:
abort(404)

View file

@ -12,7 +12,7 @@ def search_for_community(address: str):
banned = BannedInstances.query.filter_by(domain=server).first()
if banned:
reason = f" Reason: {banned.reason}" if banned.reason is not None else ''
raise Exception(f"{server} is blocked.{reason}") # todo: create custom exception class hierarchy
raise Exception(f"{server} is blocked.{reason}") # todo: create custom exception class hierarchy
already_exists = Community.query.filter_by(ap_id=address[1:]).first()
if already_exists:
@ -25,7 +25,7 @@ def search_for_community(address: str):
if webfinger_data.status_code == 200:
webfinger_json = webfinger_data.json()
for links in webfinger_json['links']:
if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile
if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile
type = links['type'] if 'type' in links else 'application/activity+json'
# retrieve the activitypub profile
community_data = get_request(links['href'], headers={'Accept': type})
@ -64,3 +64,8 @@ def search_for_community(address: str):
db.session.commit()
return community
return None
def community_url_exists(url) -> bool:
community = Community.query.filter_by(url=url).first()
return community is not None

View file

@ -1,12 +1,15 @@
from datetime import datetime
from app import db
from app.main import bp
from flask import g, jsonify, render_template, flash
from flask import g, jsonify, render_template, flash, request
from flask_moment import moment
from flask_login import current_user
from flask_babel import _, get_locale
from sqlalchemy import select
from sqlalchemy_searchable import search
from app.models import Community
from app.models import Community, CommunityMember
@bp.route('/', methods=['GET', 'POST'])
@ -19,7 +22,25 @@ def index():
@bp.route('/communities', methods=['GET'])
def list_communities():
communities = Community.query.all()
search_param = request.args.get('search', '')
if search_param == '':
communities = Community.query.all()
else:
query = search(select(Community), search_param, sort=True)
communities = db.session.scalars(query).all()
return render_template('list_communities.html', communities=communities, search=search_param)
@bp.route('/communities/local', methods=['GET'])
def list_local_communities():
communities = Community.query.filter_by(ap_id=None).all()
return render_template('list_communities.html', communities=communities)
@bp.route('/communities/subscribed', methods=['GET'])
def list_subscribed_communities():
communities = Community.query.join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all()
return render_template('list_communities.html', communities=communities)

View file

@ -55,6 +55,7 @@ class Community(db.Model):
banned = db.Column(db.Boolean, default=False)
restricted_to_mods = db.Column(db.Boolean, default=False)
searchable = db.Column(db.Boolean, default=True)
private_mods = db.Column(db.Boolean, default=False)
search_vector = db.Column(TSVectorType('name', 'title', 'description', 'rules'))
@ -180,7 +181,7 @@ class User(UserMixin, db.Model):
return True
return self.expires < datetime(2019, 9, 1)
def subscribed(self, community) -> int:
def subscribed(self, community: Community) -> int:
if community is None:
return False
subscription:CommunityMember = CommunityMember.query.filter_by(user_id=self.id, community_id=community.id).first()
@ -340,6 +341,12 @@ class Settings(db.Model):
value = db.Column(db.String(1024))
class Interest(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
communities = db.Column(db.Text)
@login.user_loader
def load_user(id):
return User.query.get(int(id))

View file

@ -27,9 +27,9 @@
<div class="row">
<div class="col-6">
{% if current_user.subscribed(community) %}
<a class="w-100 btn btn-primary" href="/c/{{ community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %}
<a class="w-100 btn btn-primary" href="/c/{{ community.link() }}/subscribe">{{ _('Subscribe') }}</a>
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/subscribe">{{ _('Subscribe') }}</a>
{% endif %}
</div>
<div class="col-6">
@ -48,8 +48,27 @@
<div class="card-body">
<p>{{ community.description }}</p>
<p>{{ community.rules }}</p>
{% if len(mods) > 0 %}
<h3>Moderators</h3>
<ol>
{% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
{% endfor %}
</ol>
{% endif %}
</div>
</div>
{% if is_moderator %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Community Settings') }}</h2>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
</div>
</div>
{% endif %}
</div>
</div>
<div class="row">

View file

@ -5,10 +5,17 @@
<div class="row g-2 justify-content-between">
<div class="col-auto">
<div class="btn-group">
<a href="#" class="btn btn-outline-secondary">{{ _('All') }}</a>
<a href="#" class="btn btn-outline-secondary">{{ _('Local') }}</a>
<a href="#" class="btn btn-outline-secondary">{{ _('Subscribed') }}</a>
<a href="/communities" class="btn {{ 'btn-primary' if request.path == '/communities' else 'btn-outline-secondary' }}">
{{ _('All') }}
</a>
<a href="/communities/local" class="btn {{ 'btn-primary' if request.path == '/communities/local' else 'btn-outline-secondary' }}">
{{ _('Local') }}
</a>
<a href="/communities/subscribed" class="btn {{ 'btn-primary' if request.path == '/communities/subscribed' else 'btn-outline-secondary' }}">
{{ _('Subscribed') }}
</a>
</div>
</div>
<div class="col-auto">
@ -17,7 +24,9 @@
<a href="{{ url_for('community.add_local') }}" class="btn btn-outline-secondary">{{ _('Create local') }}</a>
<a href="{{ url_for('community.add_remote') }}" class="btn btn-outline-secondary">{{ _('Add remote') }}</a>
</div>
<input type="search" placeholder="Find a community" class="form-control">
<form method="get" action="/communities">
<input name='search' type="search" placeholder="Find a community" class="form-control" value="{{ search }}" />
</form>
</div>
</div>
{% if len(communities) > 0 %}
@ -41,9 +50,9 @@
<td>{{ community.post_reply_count }}</td>
<td>{{ moment(community.last_active).fromNow(refresh=True) }}</td>
<td>{% if current_user.subscribed(community) %}
<a class="btn btn-primary btn-sm" href="/c/{{ community.link() }}/unsubscribe">Unsubscribe</a>
<a class="btn btn-primary btn-sm" href="/community/{{ community.link() }}/unsubscribe">Unsubscribe</a>
{% else %}
<a class="btn btn-primary btn-sm" href="/c/{{ community.link() }}/subscribe">Subscribe</a>
<a class="btn btn-primary btn-sm" href="/community/{{ community.link() }}/subscribe">Subscribe</a>
{% endif %}
</td>
</tr>

View file

@ -0,0 +1,32 @@
"""private mods
Revision ID: 755fa58fd603
Revises: feef49234599
Create Date: 2023-09-03 18:26:50.925553
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '755fa58fd603'
down_revision = 'feef49234599'
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('private_mods', sa.Boolean(), nullable=True))
# ### 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_column('private_mods')
# ### end Alembic commands ###