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
|
||||
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.
|
|
@ -27,7 +27,7 @@ mail = Mail()
|
|||
bootstrap = Bootstrap5()
|
||||
moment = Moment()
|
||||
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):
|
||||
|
|
|
@ -338,6 +338,7 @@ def shared_inbox():
|
|||
activity_log.exception_message = 'Could not detect type of like'
|
||||
if activity_log.result == 'success':
|
||||
... # 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
|
||||
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
|
||||
|
||||
|
||||
@cache.cached(150)
|
||||
@cache.memoize(150)
|
||||
def instance_blocked(host: str) -> bool:
|
||||
host = host.lower()
|
||||
if 'https://' in host or 'http://' in host:
|
||||
|
|
|
@ -49,7 +49,13 @@ def register(app):
|
|||
db.configure_mappers()
|
||||
db.create_all()
|
||||
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_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='gab.com'))
|
||||
db.session.append(BannedInstances(domain='exploding-heads.com'))
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, TextAreaField, BooleanField
|
||||
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length
|
||||
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField
|
||||
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
|
||||
|
||||
|
||||
class AddLocalCommunity(FlaskForm):
|
||||
community_name = StringField(_l('Name'), validators=[DataRequired()])
|
||||
|
@ -15,4 +17,54 @@ class AddLocalCommunity(FlaskForm):
|
|||
|
||||
class SearchRemoteCommunity(FlaskForm):
|
||||
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
|
||||
|
||||
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
|
||||
from flask_babel import _
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app import db
|
||||
from app.activitypub.signature import RsaKeys, HttpSignature
|
||||
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity
|
||||
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, CommunityJoinRequest, CommunityBan
|
||||
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost
|
||||
from app.community.util import search_for_community, community_url_exists, actor_to_community
|
||||
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, Post
|
||||
from app.community import bp
|
||||
from app.utils import get_setting, render_template
|
||||
from sqlalchemy import or_
|
||||
from app.utils import get_setting, render_template, allowlist_html
|
||||
|
||||
|
||||
@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):
|
||||
# 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:]
|
||||
private_key, public_key = RsaKeys.generate_keypair()
|
||||
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.
|
||||
def show_community(community: Community):
|
||||
mods = CommunityMember.query.filter((CommunityMember.community_id == community.id) &
|
||||
(or_(
|
||||
CommunityMember.is_owner,
|
||||
CommunityMember.is_moderator
|
||||
))
|
||||
).all()
|
||||
mods = community.moderators()
|
||||
|
||||
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)
|
||||
|
@ -107,7 +105,7 @@ def subscribe(actor):
|
|||
"to": [community.ap_id],
|
||||
"object": community.ap_id,
|
||||
"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:
|
||||
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'])
|
||||
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()
|
||||
community = actor_to_community(actor)
|
||||
|
||||
if community is not None:
|
||||
subscription = current_user.subscribed(community)
|
||||
|
@ -165,3 +159,48 @@ def unsubscribe(actor):
|
|||
return redirect('/c/' + actor)
|
||||
else:
|
||||
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:
|
||||
community = Community.query.filter_by(url=url).first()
|
||||
community = Community.query.filter_by(ap_profile_id=url).first()
|
||||
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_IMAGE = 3
|
||||
POST_TYPE_VIDEO = 4
|
||||
POST_TYPE_POLL = 5
|
||||
|
||||
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 sqlalchemy import select
|
||||
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
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
from datetime import datetime, timedelta, date
|
||||
from hashlib import md5
|
||||
from time import time
|
||||
from typing import List
|
||||
|
||||
from flask import current_app, escape
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import or_
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_babel import _, lazy_gettext as _l
|
||||
from sqlalchemy.orm import backref
|
||||
|
@ -73,6 +76,7 @@ class Community(db.Model):
|
|||
return self.icon.source_url
|
||||
return ''
|
||||
|
||||
|
||||
def header_image(self) -> str:
|
||||
if self.image_id is not None:
|
||||
if self.image.file_path is not None:
|
||||
|
@ -93,6 +97,14 @@ class Community(db.Model):
|
|||
else:
|
||||
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):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
@ -201,6 +213,10 @@ class User(UserMixin, db.Model):
|
|||
else:
|
||||
return SUBSCRIPTION_NONMEMBER
|
||||
|
||||
def communities(self) -> List[Community]:
|
||||
return Community.query.filter(Community.banned == False).\
|
||||
join(CommunityMember).filter(CommunityMember.is_banned == False).all()
|
||||
|
||||
@staticmethod
|
||||
def verify_reset_password_token(token):
|
||||
try:
|
||||
|
@ -320,6 +336,7 @@ class CommunityMember(db.Model):
|
|||
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True)
|
||||
is_moderator = 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)
|
||||
|
||||
|
||||
|
|
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
|
||||
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) {
|
||||
// Convert the title to lowercase and replace spaces with hyphens
|
||||
|
|
|
@ -239,6 +239,14 @@ nav.navbar {
|
|||
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 {
|
||||
background-color: #0071CE;
|
||||
}
|
||||
|
|
|
@ -35,6 +35,14 @@ nav.navbar {
|
|||
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 {
|
||||
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>
|
||||
<img class="community_icon_big bump_up rounded-circle" src="{{ community.icon_image() }}" />
|
||||
<h1 class="mt-2">{{ community.title }}</h1>
|
||||
{% else %}
|
||||
{% elif community.icon_image() != '' %}
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<img class="community_icon_big rounded-circle" src="{{ community.icon_image() }}" />
|
||||
|
@ -17,6 +17,8 @@
|
|||
<h1 class="mt-3">{{ community.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="mt-3">{{ community.title }}</h1>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
@ -33,7 +35,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
<form method="get">
|
||||
|
@ -46,8 +48,8 @@
|
|||
<h2>{{ _('About community') }}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{{ community.description }}</p>
|
||||
<p>{{ community.rules }}</p>
|
||||
<p>{{ community.description|safe }}</p>
|
||||
<p>{{ community.rules|safe }}</p>
|
||||
{% if len(mods) > 0 and not community.private_mods %}
|
||||
<h3>Moderators</h3>
|
||||
<ol>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import functools
|
||||
import random
|
||||
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.
|
||||
@cache.cached(timeout=50)
|
||||
@cache.memoize(timeout=50)
|
||||
def get_setting(name: str, default=None):
|
||||
setting = Settings.query.filter_by(name=name).first()
|
||||
if setting is None:
|
||||
|
@ -55,7 +54,7 @@ def get_setting(name: str, default=None):
|
|||
def set_setting(name: str, value):
|
||||
setting = Settings.query.filter_by(name=name).first()
|
||||
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:
|
||||
setting.value = json.dumps(value)
|
||||
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
|
||||
def make_shell_context():
|
||||
return {'db': db}
|
||||
return {'db': db, 'app': app}
|
||||
|
||||
|
||||
with app.app_context():
|
||||
|
|
Loading…
Reference in a new issue