mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
UI to create posts
This commit is contained in:
parent
4888e2e2e2
commit
8c3c46271d
20 changed files with 376 additions and 35 deletions
|
@ -3,4 +3,8 @@
|
||||||
Please discuss your ideas in an issue at https://codeberg.org/rimu/pyfedi/issues before
|
Please discuss your ideas in an issue at https://codeberg.org/rimu/pyfedi/issues before
|
||||||
starting any large pieces of work to ensure alignment with the roadmap, architecture and processes.
|
starting any large pieces of work to ensure alignment with the roadmap, architecture and processes.
|
||||||
|
|
||||||
|
The general style and philosphy behind the way things have been constructed is well described by
|
||||||
|
[The Grug Brained Developer](https://grugbrain.dev/). If that page resonates with you then you'll
|
||||||
|
probably enjoy your time here!
|
||||||
|
|
||||||
Mailing list, Matrix channel, etc still to come.
|
Mailing list, Matrix channel, etc still to come.
|
|
@ -27,7 +27,7 @@ mail = Mail()
|
||||||
bootstrap = Bootstrap5()
|
bootstrap = Bootstrap5()
|
||||||
moment = Moment()
|
moment = Moment()
|
||||||
babel = Babel()
|
babel = Babel()
|
||||||
cache = Cache(config={'CACHE_TYPE': os.environ.get('CACHE_TYPE'), 'CACHE_DIR': os.environ.get('CACHE_DIR') or '/dev/shm'})
|
cache = Cache()
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_class=Config):
|
def create_app(config_class=Config):
|
||||||
|
|
|
@ -338,6 +338,7 @@ def shared_inbox():
|
||||||
activity_log.exception_message = 'Could not detect type of like'
|
activity_log.exception_message = 'Could not detect type of like'
|
||||||
if activity_log.result == 'success':
|
if activity_log.result == 'success':
|
||||||
... # todo: recalculate 'hotness' of liked post/reply
|
... # todo: recalculate 'hotness' of liked post/reply
|
||||||
|
# todo: if vote was on content in local community, federate the vote out to followers
|
||||||
|
|
||||||
# Follow: remote user wants to follow one of our communities
|
# Follow: remote user wants to follow one of our communities
|
||||||
elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community
|
elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community
|
||||||
|
|
|
@ -176,7 +176,7 @@ def banned_user_agents():
|
||||||
return [] # todo: finish this function
|
return [] # todo: finish this function
|
||||||
|
|
||||||
|
|
||||||
@cache.cached(150)
|
@cache.memoize(150)
|
||||||
def instance_blocked(host: str) -> bool:
|
def instance_blocked(host: str) -> bool:
|
||||||
host = host.lower()
|
host = host.lower()
|
||||||
if 'https://' in host or 'http://' in host:
|
if 'https://' in host or 'http://' in host:
|
||||||
|
|
|
@ -49,7 +49,13 @@ def register(app):
|
||||||
db.configure_mappers()
|
db.configure_mappers()
|
||||||
db.create_all()
|
db.create_all()
|
||||||
db.session.append(Settings(name='allow_nsfw', value=json.dumps(False)))
|
db.session.append(Settings(name='allow_nsfw', value=json.dumps(False)))
|
||||||
|
db.session.append(Settings(name='allow_nsfl', value=json.dumps(False)))
|
||||||
db.session.append(Settings(name='allow_dislike', value=json.dumps(True)))
|
db.session.append(Settings(name='allow_dislike', value=json.dumps(True)))
|
||||||
|
db.session.append(Settings(name='allow_local_image_posts', value=json.dumps(True)))
|
||||||
|
db.session.append(Settings(name='allow_remote_image_posts', value=json.dumps(True)))
|
||||||
|
db.session.append(Settings(name='registration_open', value=json.dumps(True)))
|
||||||
|
db.session.append(Settings(name='approve_registrations', value=json.dumps(False)))
|
||||||
|
db.session.append(Settings(name='federation', value=json.dumps(True)))
|
||||||
db.session.append(BannedInstances(domain='lemmygrad.ml'))
|
db.session.append(BannedInstances(domain='lemmygrad.ml'))
|
||||||
db.session.append(BannedInstances(domain='gab.com'))
|
db.session.append(BannedInstances(domain='gab.com'))
|
||||||
db.session.append(BannedInstances(domain='exploding-heads.com'))
|
db.session.append(BannedInstances(domain='exploding-heads.com'))
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, SubmitField, TextAreaField, BooleanField
|
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField
|
||||||
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length
|
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
|
||||||
from flask_babel import _, lazy_gettext as _l
|
from flask_babel import _, lazy_gettext as _l
|
||||||
|
|
||||||
|
from app.utils import domain_from_url
|
||||||
|
|
||||||
|
|
||||||
class AddLocalCommunity(FlaskForm):
|
class AddLocalCommunity(FlaskForm):
|
||||||
community_name = StringField(_l('Name'), validators=[DataRequired()])
|
community_name = StringField(_l('Name'), validators=[DataRequired()])
|
||||||
|
@ -15,4 +17,54 @@ class AddLocalCommunity(FlaskForm):
|
||||||
|
|
||||||
class SearchRemoteCommunity(FlaskForm):
|
class SearchRemoteCommunity(FlaskForm):
|
||||||
address = StringField(_l('Server address'), validators=[DataRequired()])
|
address = StringField(_l('Server address'), validators=[DataRequired()])
|
||||||
submit = SubmitField(_l('Search'))
|
submit = SubmitField(_l('Search'))
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePost(FlaskForm):
|
||||||
|
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
|
||||||
|
type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs
|
||||||
|
discussion_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)})
|
||||||
|
discussion_body = TextAreaField(_l('Body'), render_kw={'placeholder': 'Text (optional)'})
|
||||||
|
link_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)})
|
||||||
|
link_url = StringField(_l('URL'), render_kw={'placeholder': 'https://...'})
|
||||||
|
image_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)})
|
||||||
|
image_file = FileField(_('Image'))
|
||||||
|
# flair = SelectField(_l('Flair'), coerce=int)
|
||||||
|
nsfw = BooleanField(_l('NSFW'))
|
||||||
|
nsfl = BooleanField(_l('NSFL'))
|
||||||
|
notify = BooleanField(_l('Send me post reply notifications'))
|
||||||
|
submit = SubmitField(_l('Post'))
|
||||||
|
|
||||||
|
def validate(self, extra_validators=None) -> bool:
|
||||||
|
if not super().validate():
|
||||||
|
return False
|
||||||
|
if self.type.data is None or self.type.data == '':
|
||||||
|
self.type.data = 'discussion'
|
||||||
|
|
||||||
|
if self.type.data == 'discussion':
|
||||||
|
if self.discussion_title.data == '':
|
||||||
|
self.discussion_title.errors.append(_('Title is required.'))
|
||||||
|
return False
|
||||||
|
elif self.type.data == 'link':
|
||||||
|
if self.link_title.data == '':
|
||||||
|
self.link_title.errors.append(_('Title is required.'))
|
||||||
|
return False
|
||||||
|
if self.link_url.data == '':
|
||||||
|
self.link_url.errors.append(_('URL is required.'))
|
||||||
|
return False
|
||||||
|
domain = domain_from_url(self.link_url.data)
|
||||||
|
if domain.banned:
|
||||||
|
self.link_url.errors.append(_(f"Links to %s are not allowed.".format(domain.name)))
|
||||||
|
return False
|
||||||
|
elif self.type.data == 'image':
|
||||||
|
if self.image_title.data == '':
|
||||||
|
self.image_title.errors.append(_('Title is required.'))
|
||||||
|
return False
|
||||||
|
if self.image_file.data == '':
|
||||||
|
self.image_file.errors.append(_('File is required.'))
|
||||||
|
return False
|
||||||
|
elif self.type.data == 'poll':
|
||||||
|
self.discussion_title.errors.append(_('Poll not implemented yet.'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import markdown2
|
||||||
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
|
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
|
from flask_login import login_user, logout_user, current_user
|
||||||
from flask_babel import _
|
from flask_babel import _
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.activitypub.signature import RsaKeys, HttpSignature
|
from app.activitypub.signature import RsaKeys, HttpSignature
|
||||||
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity
|
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost
|
||||||
from app.community.util import search_for_community, community_url_exists
|
from app.community.util import search_for_community, community_url_exists, actor_to_community
|
||||||
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER
|
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
|
||||||
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan
|
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post
|
||||||
from app.community import bp
|
from app.community import bp
|
||||||
from app.utils import get_setting, render_template
|
from app.utils import get_setting, render_template, allowlist_html
|
||||||
from sqlalchemy import or_
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/add_local', methods=['GET', 'POST'])
|
@bp.route('/add_local', methods=['GET', 'POST'])
|
||||||
|
@ -21,7 +24,7 @@ def add_local():
|
||||||
|
|
||||||
if form.validate_on_submit() and not community_url_exists(form.url.data):
|
if form.validate_on_submit() and not community_url_exists(form.url.data):
|
||||||
# todo: more intense data validation
|
# todo: more intense data validation
|
||||||
if form.url.data.trim().lower().startswith('/c/'):
|
if form.url.data.strip().lower().startswith('/c/'):
|
||||||
form.url.data = form.url.data[3:]
|
form.url.data = form.url.data[3:]
|
||||||
private_key, public_key = RsaKeys.generate_keypair()
|
private_key, public_key = RsaKeys.generate_keypair()
|
||||||
community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data,
|
community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data,
|
||||||
|
@ -65,12 +68,7 @@ def add_remote():
|
||||||
|
|
||||||
# @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird.
|
# @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):
|
def show_community(community: Community):
|
||||||
mods = CommunityMember.query.filter((CommunityMember.community_id == community.id) &
|
mods = community.moderators()
|
||||||
(or_(
|
|
||||||
CommunityMember.is_owner,
|
|
||||||
CommunityMember.is_moderator
|
|
||||||
))
|
|
||||||
).all()
|
|
||||||
|
|
||||||
is_moderator = any(mod.user_id == current_user.id for mod in mods)
|
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)
|
is_owner = any(mod.user_id == current_user.id and mod.is_owner == True for mod in mods)
|
||||||
|
@ -107,7 +105,7 @@ def subscribe(actor):
|
||||||
"to": [community.ap_id],
|
"to": [community.ap_id],
|
||||||
"object": community.ap_id,
|
"object": community.ap_id,
|
||||||
"type": "Follow",
|
"type": "Follow",
|
||||||
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/" + join_request.id
|
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{join_request.id}"
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
message = HttpSignature.signed_request(community.ap_inbox_url, follow, current_user.private_key,
|
message = HttpSignature.signed_request(community.ap_inbox_url, follow, current_user.private_key,
|
||||||
|
@ -140,11 +138,7 @@ def subscribe(actor):
|
||||||
|
|
||||||
@bp.route('/<actor>/unsubscribe', methods=['GET'])
|
@bp.route('/<actor>/unsubscribe', methods=['GET'])
|
||||||
def unsubscribe(actor):
|
def unsubscribe(actor):
|
||||||
actor = actor.strip()
|
community = actor_to_community(actor)
|
||||||
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 community is not None:
|
||||||
subscription = current_user.subscribed(community)
|
subscription = current_user.subscribed(community)
|
||||||
|
@ -165,3 +159,48 @@ def unsubscribe(actor):
|
||||||
return redirect('/c/' + actor)
|
return redirect('/c/' + actor)
|
||||||
else:
|
else:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<actor>/submit', methods=['GET', 'POST'])
|
||||||
|
def add_post(actor):
|
||||||
|
community = actor_to_community(actor)
|
||||||
|
form = CreatePost()
|
||||||
|
if get_setting('allow_nsfw', False) is False:
|
||||||
|
form.nsfw.render_kw = {'disabled': True}
|
||||||
|
if get_setting('allow_nsfl', False) is False:
|
||||||
|
form.nsfl.render_kw = {'disabled': True}
|
||||||
|
images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else ''
|
||||||
|
|
||||||
|
form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
post = Post(user_id=current_user.id, community_id=form.communities.data, nsfw=form.nsfw.data,
|
||||||
|
nsfl=form.nsfl.data)
|
||||||
|
if form.type.data == '' or form.type.data == 'discussion':
|
||||||
|
post.title = form.discussion_title.data
|
||||||
|
post.body = form.discussion_body.data
|
||||||
|
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True))
|
||||||
|
post.type = POST_TYPE_ARTICLE
|
||||||
|
elif form.type.data == 'link':
|
||||||
|
post.title = form.link_title.data
|
||||||
|
post.url = form.link_url.data
|
||||||
|
post.type = POST_TYPE_LINK
|
||||||
|
elif form.type.data == 'image':
|
||||||
|
post.title = form.image_title.data
|
||||||
|
post.type = POST_TYPE_IMAGE
|
||||||
|
# todo: handle file upload
|
||||||
|
elif form.type.data == 'poll':
|
||||||
|
...
|
||||||
|
else:
|
||||||
|
raise Exception('invalid post type')
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Post has been added')
|
||||||
|
return redirect(f"/c/{community.link()}")
|
||||||
|
else:
|
||||||
|
form.communities.data = community.id
|
||||||
|
form.notify.data = True
|
||||||
|
|
||||||
|
return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community,
|
||||||
|
images_disabled=images_disabled)
|
||||||
|
|
|
@ -67,5 +67,14 @@ def search_for_community(address: str):
|
||||||
|
|
||||||
|
|
||||||
def community_url_exists(url) -> bool:
|
def community_url_exists(url) -> bool:
|
||||||
community = Community.query.filter_by(url=url).first()
|
community = Community.query.filter_by(ap_profile_id=url).first()
|
||||||
return community is not None
|
return community is not None
|
||||||
|
|
||||||
|
|
||||||
|
def actor_to_community(actor) -> Community:
|
||||||
|
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()
|
||||||
|
return community
|
||||||
|
|
|
@ -4,6 +4,7 @@ POST_TYPE_LINK = 1
|
||||||
POST_TYPE_ARTICLE = 2
|
POST_TYPE_ARTICLE = 2
|
||||||
POST_TYPE_IMAGE = 3
|
POST_TYPE_IMAGE = 3
|
||||||
POST_TYPE_VIDEO = 4
|
POST_TYPE_VIDEO = 4
|
||||||
|
POST_TYPE_POLL = 5
|
||||||
|
|
||||||
DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ from flask_login import current_user
|
||||||
from flask_babel import _, get_locale
|
from flask_babel import _, get_locale
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy_searchable import search
|
from sqlalchemy_searchable import search
|
||||||
from app.utils import render_template
|
from app.utils import render_template, get_setting
|
||||||
|
|
||||||
from app.models import Community, CommunityMember
|
from app.models import Community, CommunityMember
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
from time import time
|
from time import time
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from flask import current_app, escape
|
from flask import current_app, escape
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
|
from sqlalchemy import or_
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask_babel import _, lazy_gettext as _l
|
from flask_babel import _, lazy_gettext as _l
|
||||||
from sqlalchemy.orm import backref
|
from sqlalchemy.orm import backref
|
||||||
|
@ -73,6 +76,7 @@ class Community(db.Model):
|
||||||
return self.icon.source_url
|
return self.icon.source_url
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def header_image(self) -> str:
|
def header_image(self) -> str:
|
||||||
if self.image_id is not None:
|
if self.image_id is not None:
|
||||||
if self.image.file_path is not None:
|
if self.image.file_path is not None:
|
||||||
|
@ -93,6 +97,14 @@ class Community(db.Model):
|
||||||
else:
|
else:
|
||||||
return self.ap_id
|
return self.ap_id
|
||||||
|
|
||||||
|
def moderators(self):
|
||||||
|
return CommunityMember.query.filter((CommunityMember.community_id == self.id) &
|
||||||
|
(or_(
|
||||||
|
CommunityMember.is_owner,
|
||||||
|
CommunityMember.is_moderator
|
||||||
|
))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
@ -201,6 +213,10 @@ class User(UserMixin, db.Model):
|
||||||
else:
|
else:
|
||||||
return SUBSCRIPTION_NONMEMBER
|
return SUBSCRIPTION_NONMEMBER
|
||||||
|
|
||||||
|
def communities(self) -> List[Community]:
|
||||||
|
return Community.query.filter(Community.banned == False).\
|
||||||
|
join(CommunityMember).filter(CommunityMember.is_banned == False).all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_reset_password_token(token):
|
def verify_reset_password_token(token):
|
||||||
try:
|
try:
|
||||||
|
@ -320,6 +336,7 @@ class CommunityMember(db.Model):
|
||||||
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True)
|
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True)
|
||||||
is_moderator = db.Column(db.Boolean, default=False)
|
is_moderator = db.Column(db.Boolean, default=False)
|
||||||
is_owner = db.Column(db.Boolean, default=False)
|
is_owner = db.Column(db.Boolean, default=False)
|
||||||
|
is_banned = db.Column(db.Boolean, default=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|
50
app/sorting.py
Normal file
50
app/sorting.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
# from https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
|
||||||
|
|
||||||
|
from math import sqrt, log
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
epoch = datetime(1970, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def epoch_seconds(date):
|
||||||
|
td = date - epoch
|
||||||
|
return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
|
||||||
|
|
||||||
|
|
||||||
|
def score(ups, downs):
|
||||||
|
return ups - downs
|
||||||
|
|
||||||
|
|
||||||
|
# used for ranking stories
|
||||||
|
def hot(ups, downs, date):
|
||||||
|
s = score(ups, downs)
|
||||||
|
order = log(max(abs(s), 1), 10)
|
||||||
|
sign = 1 if s > 0 else -1 if s < 0 else 0
|
||||||
|
seconds = epoch_seconds(date) - 1134028003 # this value seems to be an arbitrary time in 2005.
|
||||||
|
return round(sign * order + seconds / 45000, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# used for ranking comments
|
||||||
|
def _confidence(ups, downs):
|
||||||
|
n = ups + downs
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
z = 1.281551565545
|
||||||
|
p = float(ups) / n
|
||||||
|
|
||||||
|
left = p + 1 / (2 * n) * z * z
|
||||||
|
right = z * sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
||||||
|
under = 1 + 1 / n * z * z
|
||||||
|
|
||||||
|
return (left - right) / under
|
||||||
|
|
||||||
|
|
||||||
|
def confidence(ups, downs):
|
||||||
|
if ups + downs == 0:
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
return _confidence(ups, downs)
|
|
@ -6,7 +6,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
// fires after all resources have loaded, including stylesheets and js files
|
// fires after all resources have loaded, including stylesheets and js files
|
||||||
window.addEventListener("load", function () {
|
window.addEventListener("load", function () {
|
||||||
|
setupPostTypeTabs(); // when choosing the type of your new post, store the chosen tab in a hidden field so the backend knows which fields to check
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,6 +21,34 @@ function setupCommunityNameInput() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupPostTypeTabs() {
|
||||||
|
|
||||||
|
const tabEl = document.querySelector('#discussion-tab')
|
||||||
|
if(tabEl) {
|
||||||
|
tabEl.addEventListener('show.bs.tab', event => {
|
||||||
|
document.getElementById('type').value = 'discussion';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tabE2 = document.querySelector('#link-tab')
|
||||||
|
if(tabE2) {
|
||||||
|
tabE2.addEventListener('show.bs.tab', event => {
|
||||||
|
document.getElementById('type').value = 'link';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tabE3 = document.querySelector('#image-tab')
|
||||||
|
if(tabE3) {
|
||||||
|
tabE3.addEventListener('show.bs.tab', event => {
|
||||||
|
document.getElementById('type').value = 'image';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tabE4 = document.querySelector('#poll-tab')
|
||||||
|
if(tabE4) {
|
||||||
|
tabE4.addEventListener('show.bs.tab', event => {
|
||||||
|
document.getElementById('type').value = 'poll';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function titleToURL(title) {
|
function titleToURL(title) {
|
||||||
// Convert the title to lowercase and replace spaces with hyphens
|
// Convert the title to lowercase and replace spaces with hyphens
|
||||||
|
|
|
@ -239,6 +239,14 @@ nav.navbar {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-content > .tab-pane {
|
||||||
|
border-right: solid 1px #dee2e6;
|
||||||
|
border-bottom: solid 1px #dee2e6;
|
||||||
|
border-left: solid 1px #dee2e6;
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
padding: 10px 15px 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu .dropdown-item.active {
|
.dropdown-menu .dropdown-item.active {
|
||||||
background-color: #0071CE;
|
background-color: #0071CE;
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,14 @@ nav.navbar {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-content > .tab-pane {
|
||||||
|
border-right: solid 1px #dee2e6;
|
||||||
|
border-bottom: solid 1px #dee2e6;
|
||||||
|
border-left: solid 1px #dee2e6;
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
padding: 10px 15px 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
.dropdown-item.active {
|
.dropdown-item.active {
|
||||||
background-color: $primary-colour;
|
background-color: $primary-colour;
|
||||||
|
|
69
app/templates/community/add_post.html
Normal file
69
app/templates/community/add_post.html
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'bootstrap/form.html' import render_form, render_field %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8 position-relative">
|
||||||
|
<h1>{{ _('Create post') }}</h1>
|
||||||
|
<form method="post">
|
||||||
|
{{ form.csrf_token() }}
|
||||||
|
{{ render_field(form.communities) }}
|
||||||
|
<nav id="post_type_chooser">
|
||||||
|
<div class="nav nav-tabs nav-justified" id="typeTab" role="tablist">
|
||||||
|
<button class="nav-link active" id="discussion-tab" data-bs-toggle="tab" data-bs-target="#discussion-tab-pane"
|
||||||
|
type="button" role="tab" aria-controls="discussion-tab-pane" aria-selected="true">Discussion</button>
|
||||||
|
<button class="nav-link" id="link-tab" data-bs-toggle="tab" data-bs-target="#link-tab-pane"
|
||||||
|
type="button" role="tab" aria-controls="link-tab-pane" aria-selected="false">Link</button>
|
||||||
|
<button class="nav-link" id="image-tab" data-bs-toggle="tab" data-bs-target="#image-tab-pane"
|
||||||
|
type="button" role="tab" aria-controls="image-tab-pane" aria-selected="false" {{ images_disabled }}>Image</button>
|
||||||
|
<button class="nav-link" id="poll-tab" data-bs-toggle="tab" data-bs-target="#poll-tab-pane"
|
||||||
|
type="button" role="tab" aria-controls="poll-tab-pane" aria-selected="false" disabled>Poll</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="tab-content" id="myTabContent">
|
||||||
|
<div class="tab-pane fade show active" id="discussion-tab-pane" role="tabpanel" aria-labelledby="home-tab" tabindex="0">
|
||||||
|
{{ render_field(form.discussion_title) }}
|
||||||
|
{{ render_field(form.discussion_body) }}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
|
||||||
|
{{ render_field(form.link_title) }}
|
||||||
|
{{ render_field(form.link_url) }}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
|
||||||
|
{{ render_field(form.image_title) }}
|
||||||
|
{{ render_field(form.image_file) }}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="poll-tab-pane" role="tabpanel" aria-labelledby="disabled-tab" tabindex="0">
|
||||||
|
Poll
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ render_field(form.type) }}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-1">
|
||||||
|
{{ render_field(form.nsfw) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
{{ render_field(form.nsfl) }}
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ render_field(form.notify) }}
|
||||||
|
{{ render_field(form.submit) }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>{{ community.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>{{ community.description|safe }}</p>
|
||||||
|
<p>{{ community.rules|safe }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -8,7 +8,7 @@
|
||||||
<div class="community_header" style="height: 240px; background-image: url({{ community.header_image() }});"></div>
|
<div class="community_header" style="height: 240px; background-image: url({{ community.header_image() }});"></div>
|
||||||
<img class="community_icon_big bump_up rounded-circle" src="{{ community.icon_image() }}" />
|
<img class="community_icon_big bump_up rounded-circle" src="{{ community.icon_image() }}" />
|
||||||
<h1 class="mt-2">{{ community.title }}</h1>
|
<h1 class="mt-2">{{ community.title }}</h1>
|
||||||
{% else %}
|
{% elif community.icon_image() != '' %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<img class="community_icon_big rounded-circle" src="{{ community.icon_image() }}" />
|
<img class="community_icon_big rounded-circle" src="{{ community.icon_image() }}" />
|
||||||
|
@ -17,6 +17,8 @@
|
||||||
<h1 class="mt-3">{{ community.title }}</h1>
|
<h1 class="mt-3">{{ community.title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="mt-3">{{ community.title }}</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,7 +35,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<a class="w-100 btn btn-primary" href="#">{{ _('Create a post') }}</a>
|
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/submit">{{ _('Create a post') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="get">
|
<form method="get">
|
||||||
|
@ -46,8 +48,8 @@
|
||||||
<h2>{{ _('About community') }}</h2>
|
<h2>{{ _('About community') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>{{ community.description }}</p>
|
<p>{{ community.description|safe }}</p>
|
||||||
<p>{{ community.rules }}</p>
|
<p>{{ community.rules|safe }}</p>
|
||||||
{% if len(mods) > 0 and not community.private_mods %}
|
{% if len(mods) > 0 and not community.private_mods %}
|
||||||
<h3>Moderators</h3>
|
<h3>Moderators</h3>
|
||||||
<ol>
|
<ol>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import functools
|
|
||||||
import random
|
import random
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
@ -42,7 +41,7 @@ def get_request(uri, params=None, headers=None) -> requests.Response:
|
||||||
|
|
||||||
|
|
||||||
# saves an arbitrary object into a persistent key-value store. cached.
|
# saves an arbitrary object into a persistent key-value store. cached.
|
||||||
@cache.cached(timeout=50)
|
@cache.memoize(timeout=50)
|
||||||
def get_setting(name: str, default=None):
|
def get_setting(name: str, default=None):
|
||||||
setting = Settings.query.filter_by(name=name).first()
|
setting = Settings.query.filter_by(name=name).first()
|
||||||
if setting is None:
|
if setting is None:
|
||||||
|
@ -55,7 +54,7 @@ def get_setting(name: str, default=None):
|
||||||
def set_setting(name: str, value):
|
def set_setting(name: str, value):
|
||||||
setting = Settings.query.filter_by(name=name).first()
|
setting = Settings.query.filter_by(name=name).first()
|
||||||
if setting is None:
|
if setting is None:
|
||||||
db.session.append(Settings(name=name, value=json.dumps(value)))
|
db.session.add(Settings(name=name, value=json.dumps(value)))
|
||||||
else:
|
else:
|
||||||
setting.value = json.dumps(value)
|
setting.value = json.dumps(value)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
48
migrations/versions/f8200275644a_community_ban_field.py
Normal file
48
migrations/versions/f8200275644a_community_ban_field.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
"""community ban field
|
||||||
|
|
||||||
|
Revision ID: f8200275644a
|
||||||
|
Revises: 6b84580a94cd
|
||||||
|
Create Date: 2023-09-16 19:21:13.085722
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f8200275644a'
|
||||||
|
down_revision = '6b84580a94cd'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('community_member', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_banned', sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('post', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('comments_enabled', sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('post_reply', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('body_html_safe', sa.Boolean(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('ap_create_id', sa.String(length=100), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('ap_announce_id', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('post_reply', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('ap_announce_id')
|
||||||
|
batch_op.drop_column('ap_create_id')
|
||||||
|
batch_op.drop_column('body_html_safe')
|
||||||
|
|
||||||
|
with op.batch_alter_table('post', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('comments_enabled')
|
||||||
|
|
||||||
|
with op.batch_alter_table('community_member', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_banned')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -20,7 +20,7 @@ def app_context_processor(): # NB there needs to be an identical function in cb
|
||||||
|
|
||||||
@app.shell_context_processor
|
@app.shell_context_processor
|
||||||
def make_shell_context():
|
def make_shell_context():
|
||||||
return {'db': db}
|
return {'db': db, 'app': app}
|
||||||
|
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|
Loading…
Reference in a new issue