Semantic HTML and post feed #23

Merged
Jaculabilis merged 10 commits from tvb/post-feed into develop 2021-10-02 00:18:32 +00:00
29 changed files with 340 additions and 94 deletions

View File

@ -2,19 +2,19 @@
Post query interface
"""
import re
from typing import Optional, Sequence, Tuple
from sqlalchemy import select
from sqlalchemy import select, update, func, or_, DateTime
from amanuensis.db import DbContext, Post
from amanuensis.db.models import Lexicon
from amanuensis.db.models import Lexicon, Membership
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
def create(
db: DbContext,
lexicon_id: int,
user_id: int,
user_id: Optional[int],
body: str,
) -> Post:
"""
@ -47,3 +47,57 @@ def create(
db.session.add(new_post)
db.session.commit()
return new_post
def get_posts_for_membership(
db: DbContext, membership_id: int
) -> Tuple[Sequence[Post], Sequence[Post]]:
"""
Returns posts for the membership's lexicon, split into posts that
are new since the last view and posts that were previously seen.
"""
# Save the current timestamp, so we don't miss posts created between now
# and when we finish looking stuff up
now: DateTime = db(select(func.now())).scalar_one()
# Save the previous last-seen timestamp for splitting new from old posts,
# then update the membership with the current time
last_seen: DateTime = db(
select(Membership.last_post_seen).where(Membership.id == membership_id)
).scalar_one()
db(
update(Membership)
.where(Membership.id == membership_id)
.values(last_post_seen=now)
)
db.session.commit()
# Fetch posts in two groups, new ones after the last-seen time and old ones
# If last-seen is null, then just return everything as new
new_posts = db(
select(Post)
.where(last_seen is None or Post.created > last_seen)
.order_by(Post.created.desc())
).scalars()
old_posts = db(
select(Post)
.where(last_seen is not None and Post.created <= last_seen)
.order_by(Post.created.desc())
).scalars()
return new_posts, old_posts
def get_unread_count(db: DbContext, membership_id: int) -> int:
"""Get the number of posts that the member has not seen"""
return db(
select(func.count(Post.id))
.join(Membership, Membership.lexicon_id == Post.lexicon_id)
.where(
or_(
Membership.last_post_seen.is_(None),
Post.created > Membership.last_post_seen,
)
)
.where(Membership.id == membership_id)
).scalar()

View File

@ -8,6 +8,7 @@ import amanuensis.cli.admin
import amanuensis.cli.character
import amanuensis.cli.index
import amanuensis.cli.lexicon
import amanuensis.cli.post
import amanuensis.cli.user
from amanuensis.db import DbContext
@ -113,6 +114,7 @@ def main():
add_subcommand(subparsers, amanuensis.cli.character)
add_subcommand(subparsers, amanuensis.cli.index)
add_subcommand(subparsers, amanuensis.cli.lexicon)
add_subcommand(subparsers, amanuensis.cli.post)
add_subcommand(subparsers, amanuensis.cli.user)
# Parse args and perform top-level arg processing

31
amanuensis/cli/post.py Normal file
View File

@ -0,0 +1,31 @@
import logging
from amanuensis.backend import *
from amanuensis.db import *
from .helpers import add_argument
COMMAND_NAME = "post"
COMMAND_HELP = "Interact with posts."
LOG = logging.getLogger(__name__)
@add_argument("--lexicon", required=True, help="The lexicon's name")
@add_argument("--by", help="The character's public id")
@add_argument("--text", help="The text of the post")
def command_create(args) -> int:
"""
Create a post in a lexicon.
"""
db: DbContext = args.get_db()
lexicon = lexiq.try_from_name(db, args.lexicon)
if not lexicon:
raise ValueError("Lexicon does not exist")
user = userq.try_from_username(db, args.by)
user_id = user.id if user else None
post: Post = postq.create(db, lexicon.id, user_id, args.text)
preview = post.body[:20] + "..." if len(post.body) > 20 else post.body
LOG.info(f"Posted '{preview}' in {lexicon.full_title}")
return 0

View File

@ -251,7 +251,9 @@ class Lexicon(ModelBase):
indices = relationship("ArticleIndex", back_populates="lexicon")
index_rules = relationship("ArticleIndexRule", back_populates="lexicon")
content_rules = relationship("ArticleContentRule", back_populates="lexicon")
posts = relationship("Post", back_populates="lexicon")
posts = relationship(
"Post", back_populates="lexicon", order_by="Post.created.desc()"
)
#######################
# Derived information #

View File

@ -17,7 +17,7 @@ div#editor-left {
display: flex;
align-items: stretch;
}
div#editor-left div.contentblock {
div#editor-left section {
display: flex;
flex-direction: column;
margin: 10px 5px 10px 10px;
@ -48,7 +48,7 @@ textarea#editor-content {
div#editor-right {
overflow-y: scroll;
}
div#editor-right div.contentblock {
div#editor-right section {
margin: 10px 5px 10px 10px;
}
span.message-warning {

View File

@ -8,14 +8,14 @@ body {
line-height: 1.4;
font-size: 16px;
}
div#wrapper {
main {
max-width: 800px;
position: absolute;
left: 0;
right: 0;
margin: 0 auto;
}
div#header {
header {
padding: 5px;
margin: 5px;
background-color: #ffffff;
@ -24,7 +24,7 @@ div#header {
overflow: auto;
position: relative;
}
div#header p, div#header h2 {
header p, header h2 {
margin: 5px;
}
div#login-status {
@ -33,7 +33,7 @@ div#login-status {
top: 10px;
right: 10px;
}
div#sidebar {
nav {
width: 200px;
float:left;
margin:5px;
@ -46,7 +46,7 @@ div#sidebar {
img#logo {
max-width: 200px;
}
div#sidebar table {
nav table {
width: 100%;
border-collapse: collapse;
}
@ -63,32 +63,32 @@ table a {
border-radius: 5px;
text-decoration: none;
}
div#sidebar table a {
nav table a {
justify-content: center;
}
table a:hover {
background-color: var(--button-hover);
}
div#sidebar table a.current-page {
nav table a.current-page {
background-color: var(--button-current);
}
div#sidebar table a.current-page:hover {
nav table a.current-page:hover {
background-color: var(--button-current);
}
div#sidebar table td {
nav table td {
padding: 0px; margin: 3px 0;
border-bottom: 8px solid transparent;
}
div#content {
article {
margin: 5px;
}
div.content-2col {
article.content-2col {
position: absolute;
right: 0px;
left: 226px;
max-width: 564px;
}
div.contentblock {
section {
background-color: #ffffff;
box-shadow: 2px 2px 10px #888888;
margin-bottom: 5px;
@ -215,27 +215,35 @@ details.setting-help {
#index-definition-table td input[type=number] {
width: 4em;
}
section.new-post {
padding: 9px;
border: 1px dashed red;
}
p.post-byline {
color: #606060;
text-align: right;
}
@media only screen and (max-width: 816px) {
div#wrapper {
main {
padding: 5px;
}
div#header {
header {
max-width: 554px;
margin: 0 auto;
}
div#sidebar {
nav {
max-width: 548px;
width: inherit;
float: inherit;
margin: 10px auto;
}
div#content{
article{
margin: 10px auto;
}
div.content-1col {
article.content-1col {
max-width: 564px;
}
div.content-2col {
article.content-2col {
max-width: 564px;
position: static;
right: inherit;

View File

@ -76,6 +76,7 @@ def get_app(
"memq": memq,
"charq": charq,
"indq": indq,
"postq": postq,
"current_lexicon": current_lexicon,
"current_membership": current_membership
}

View File

@ -3,6 +3,7 @@
{% block header %}<h2>Amanuensis - Login</h2>{% endblock %}
{% block login_status_attr %}style="display:none"{% endblock %}
{% block main %}
<section>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>{{ form.username.label }}<br>{{ form.username(size=32) }}
@ -19,5 +20,5 @@
{% for message in get_flashed_messages() %}
<span style="color: #ff0000">{{ message }}</span><br>
{% endfor %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -99,7 +99,7 @@ def admin_required(route):
@wraps(route)
def admin_route(*args, **kwargs):
user: User = current_user
if not user.is_site_admin:
if not user.is_authenticated or not user.is_site_admin:
flash("You must be an admin to view this page")
return redirect(url_for('home.home'))
return route(*args, **kwargs)
@ -114,7 +114,13 @@ def player_required(route):
def player_route(*args, **kwargs):
db: DbContext = g.db
user: User = current_user
lexicon: Lexicon = g.lexicon
lexicon: Lexicon = current_lexicon
if not user.is_authenticated:
flash("You must be a player to view this page")
if lexicon.public:
return redirect(url_for('lexicon.contents', lexicon_name=lexicon.name))
else:
return redirect(url_for('home.home'))
mem: Optional[Membership] = memq.try_from_ids(db, user.id, lexicon.id)
if not mem:
flash("You must be a player to view this page")
@ -134,8 +140,8 @@ def player_required_if_not_public(route):
def player_route(*args, **kwargs):
db: DbContext = g.db
user: User = current_user
lexicon: Lexicon = g.lexicon
if not lexicon.public:
lexicon: Lexicon = current_lexicon
if not user.is_authenticated and not lexicon.public:
mem: Optional[Membership] = memq.try_from_ids(db, user.id, lexicon.id)
if not mem:
flash("You must be a player to view this page")
@ -152,7 +158,13 @@ def editor_required(route):
def editor_route(*args, **kwargs):
db: DbContext = g.db
user: User = current_user
lexicon: Lexicon = g.lexicon
lexicon: Lexicon = current_lexicon
if not user.is_authenticated:
flash("You must be a player to view this page")
if lexicon.public:
return redirect(url_for('lexicon.contents', lexicon_name=lexicon.name))
else:
return redirect(url_for('home.home'))
mem: Optional[Membership] = memq.try_from_ids(db, user.id, lexicon.id)
if not mem or not mem.is_editor:
flash("You must be the editor to view this page")

View File

@ -9,6 +9,7 @@
{% set template_sidebar_rows = [self.sb_home(), self.sb_create()] %}
{% block main %}
<section>
<p>Users:</p>
{% for user in userq.get_all(db) %}
{{ macros.dashboard_user_item(user) }}
@ -17,5 +18,5 @@
{% for lexicon in lexiq.get_all(db) %}
{{ macros.dashboard_lexicon_item(lexicon) }}
{% endfor %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -4,6 +4,13 @@
{% block header %}<h2>Amanuensis - Home</h2>{% endblock %}
{% block main %}
{% if current_user.is_site_admin %}
<section>
<a href="{{ url_for('home.admin') }}" style="display:block; text-align:center;">Admin dashboard</a>
</section>
{% endif %}
<section>
<h1>Welcome to Amanuensis!</h1>
<p>Amanuensis is a hub for playing Lexicon, the encyclopedia RPG. Log in to access your Lexicon games. If you do not have an account, contact the administrator.</p>
@ -37,13 +44,5 @@
{% else %}
<p>No public games available.</p>
{% endif %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}
{% if current_user.is_site_admin %}
{% block admin_dash %}
<a href="{{ url_for('home.admin') }}" style="display:block; text-align:center;">Admin dashboard</a>
{% endblock %}
{% set template_content_blocks = [self.admin_dash()] + template_content_blocks %}
{% endif %}

View File

@ -15,6 +15,10 @@
{% if current_page == "contents" %}class="current-page"
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
{% endif %}>Contents</a>{% endblock %}
{% block sb_posts %}<a
{% if current_page == "posts" %}class="current-page"
{% else %}href="{{ url_for('lexicon.posts.list', lexicon_name=g.lexicon.name) }}"
{% endif %}>Posts{% set unread_count = postq.get_unread_count(g.db, current_membership.id) if current_membership else None %}{% if unread_count %} ({{ unread_count }}){% endif %}</a>{% endblock %}
{% block sb_rules %}<a
{% if current_page == "rules" %}class="current-page"
{% else %}href="{{ url_for('lexicon.rules', lexicon_name=g.lexicon.name) }}"
@ -31,6 +35,7 @@
{% set template_sidebar_rows = [
self.sb_characters(),
self.sb_contents(),
self.sb_posts(),
self.sb_rules(),
self.sb_settings(),
self.sb_stats()] %}

View File

@ -8,6 +8,7 @@ from amanuensis.server.helpers import lexicon_param, player_required_if_not_publ
from .characters import bp as characters_bp
from .forms import LexiconJoinForm
from .posts import bp as posts_bp
from .settings import bp as settings_bp
@ -15,6 +16,7 @@ bp = Blueprint(
"lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="."
)
bp.register_blueprint(characters_bp)
bp.register_blueprint(posts_bp)
bp.register_blueprint(settings_bp)

View File

@ -2,6 +2,7 @@
{% block title %}Edit {{ character.name }} | {{ lexicon_title }}{% endblock %}
{% block main %}
<section>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
@ -19,6 +20,5 @@
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -3,6 +3,7 @@
{% block title %}Character | {{ lexicon_title }}{% endblock %}
{% block main %}
<section>
<h1>Characters</h1>
{% set players = memq.get_players_in_lexicon(db, g.lexicon.id)|list %}
{% set characters = charq.get_in_lexicon(db, g.lexicon.id)|list %}
@ -30,5 +31,5 @@
</li>
{% endfor %}
</ul>
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -2,17 +2,15 @@
{% block title %}{{ article.title }} | {{ lexicon_title }}{% endblock %}
{% block main %}
<section>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
<h1>{{ article.title }}</h1>
{{ article.html }}
{% endblock %}
{% block citations %}
</section>
<section>
<p>
{% for citation in article.cites %}
<a href="{{ citation|articlelink }}">{{ citation }}</a>{% if not loop.last %} / {% endif %}
@ -23,6 +21,5 @@
<a href="{{ citation|articlelink }}">{{ citation }}</a>{% if not loop.last %} / {% endif %}
{% endfor %}
</p>
</section>
{% endblock %}
{% set template_content_blocks = [self.main(), self.citations()] %}

View File

@ -3,7 +3,7 @@
{% block title %}Index | {{ lexicon_title }}{% endblock %}
{% block main %}
<section>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
@ -20,6 +20,5 @@
</ul>
{% endif %}
{% endfor %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -2,7 +2,7 @@
{% block title %}Join | {{ g.lexicon.full_title }}{% endblock %}
{% block main %}
<section>
<form id="lexicon-join" action="" method="post" novalidate>
{{ form.hidden_tag() }}
{% if g.lexicon.join_password %}
@ -16,6 +16,5 @@
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -3,8 +3,7 @@
{% block title %}Rules | {{ lexicon_title }}{% endblock %}
{% block main %}
<section>
Placeholder text
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -3,8 +3,7 @@
{% block title %}Session | {{ lexicon_title }}{% endblock %}
{% block main %}
<section>
Placeholder text
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -0,0 +1,95 @@
from flask import Blueprint, render_template, g
from flask.helpers import url_for
from flask_login import current_user
from werkzeug.utils import redirect
from amanuensis.backend import postq
from amanuensis.db import Post
from amanuensis.parser import RenderableVisitor, parse_raw_markdown
from amanuensis.parser.core import *
from amanuensis.server.helpers import (
lexicon_param,
player_required,
current_lexicon,
current_membership,
)
from .forms import CreatePostForm
bp = Blueprint("posts", __name__, url_prefix="/posts", template_folder=".")
class PostFormatter(RenderableVisitor):
"""Parses stylistic markdown into HTML without links."""
def TextSpan(self, span: TextSpan):
return span.innertext
def LineBreak(self, span: LineBreak):
return "<br>"
def ParsedArticle(self, span: ParsedArticle):
return "\n".join(span.recurse(self))
def BodyParagraph(self, span: BodyParagraph):
return f'<p>{"".join(span.recurse(self))}</p>'
def SignatureParagraph(self, span: SignatureParagraph):
return (
'<hr><span class="signature"><p>'
f'{"".join(span.recurse(self))}'
"</p></span>"
)
def BoldSpan(self, span: BoldSpan):
return f'<b>{"".join(span.recurse(self))}</b>'
def ItalicSpan(self, span: ItalicSpan):
return f'<i>{"".join(span.recurse(self))}</i>'
def CitationSpan(self, span: CitationSpan):
return "".join(span.recurse(self))
def render_post_body(post: Post) -> str:
"""Parse and render the body of a post into post-safe HTML."""
renderable: ParsedArticle = parse_raw_markdown(post.body)
rendered: str = renderable.render(PostFormatter())
return rendered
@bp.get("/")
@lexicon_param
@player_required
def list(lexicon_name):
form = CreatePostForm()
new_posts, old_posts = postq.get_posts_for_membership(g.db, current_membership.id)
return render_template(
"posts.jinja",
lexicon_name=lexicon_name,
form=form,
render_post_body=render_post_body,
new_posts=new_posts,
old_posts=old_posts,
)
@bp.post("/")
@lexicon_param
@player_required
def create(lexicon_name):
form = CreatePostForm()
if form.validate():
# Data is valid
postq.create(g.db, current_lexicon.id, current_user.id, form.body.data)
return redirect(url_for("lexicon.posts.list", lexicon_name=lexicon_name))
else:
# POST received invalid data
return render_template(
"posts.jinja",
lexicon_name=lexicon_name,
form=form,
render_post_body=render_post_body,
)

View File

@ -0,0 +1,10 @@
from flask_wtf import FlaskForm
from wtforms import SubmitField, TextAreaField
from wtforms.validators import DataRequired
class CreatePostForm(FlaskForm):
"""/lexicon/<name>/posts/"""
body = TextAreaField(validators=[DataRequired()])
submit = SubmitField("Post")

View File

@ -0,0 +1,28 @@
{% extends "lexicon.jinja" %}
{% set current_page = "posts" %}
{% block title %}Character | {{ lexicon_title }}{% endblock %}
{% macro make_post(post, is_new) %}
<section{% if is_new %} class="new-post"{% endif %}>
<p>{{ render_post_body(post) }}</p>
<p class="post-byline">Posted {% if post.user_id %}by {{ post.user.display_name }} {% endif %}at {{ post.created }}</p>
</section>
{% endmacro %}
{% block main %}
{% if current_lexicon.allow_post %}
<section>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>{{ form.body(class_='fullwidth') }}</p>
<p>{{ form.submit() }}</p>
</form>
</section>
{% endif %}
{% for post in new_posts %}
{{ make_post(post, True) }}
{% endfor %}
{% for post in old_posts %}
{{ make_post(post, False) }}
{% endfor %}
{% endblock %}

View File

@ -89,6 +89,7 @@ def setup(lexicon_name):
form.turn_count.data = lexicon.turn_count
form.player_limit.data = lexicon.player_limit
form.character_limit.data = lexicon.character_limit
form.allow_post.data = lexicon.allow_post
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
@ -109,6 +110,7 @@ def setup(lexicon_name):
lexicon.turn_count = form.turn_count.data
lexicon.player_limit = form.player_limit.data
lexicon.character_limit = form.character_limit.data
lexicon.allow_post = form.allow_post.data
g.db.session.commit() # TODO refactor into backend
flash("Settings saved")
return redirect(

View File

@ -49,6 +49,7 @@ class SetupSettingsForm(FlaskForm):
widget=NumberInput(),
validators=[Optional()],
)
allow_post = BooleanField("Allow players to make posts")
submit = SubmitField("Submit")

View File

@ -1,4 +1,5 @@
{% extends "lexicon.jinja" %}
{% set current_page = "settings" %}
{% block title %}Edit | {{ lexicon_title }}{% endblock %}
{% macro settings_page_link(page, text) -%}
@ -19,6 +20,7 @@
{% endmacro %}
{% block main %}
<section>
{% if current_membership.is_editor %}
<ul class="unordered-tabs">
<li>{{ settings_page_link("player", "Player Settings") }}</li>
@ -78,6 +80,7 @@
<p>
{{ number_setting(form.character_limit) }}
</p>
<p>{{ flag_setting(form.allow_post) }}</p>
<p>{{ form.submit() }}</p>
</form>
{% for message in get_flashed_messages() %}
@ -172,6 +175,5 @@
{% if page_name == "article" %}
<h3>Article Requirements</h3>
{% endif %}
</section>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -8,8 +8,8 @@
<link rel="stylesheet" href="{{ url_for('static', filename='page.css') }}">
</head>
<body>
<div id="wrapper">
<div id="header">
<main>
<header>
<div id="login-status" {% block login_status_attr %}{% endblock %}>
{% if current_user.is_authenticated %}
<b>{{ current_user.username -}}</b>
@ -24,15 +24,11 @@
{% endif %}
</div>
{% block header %}{% endblock %}
</div>
</header>
{% block sidebar %}{% endblock %}
<div id="content" class="{% block content_class %}{% endblock %}">
{% if not template_content_blocks %}{% set template_content_blocks = [] %}{% endif %}
{% if not content_blocks %}{% set content_blocks = [] %}{% endif %}
{% for content_block in template_content_blocks + content_blocks %}<div class="contentblock">
{{ content_block|safe }}</div>
{% endfor %}
</div>
</div>
<article class="{% block content_class %}{% endblock %}">
{% block main %}{% endblock %}
</article>
</main>
</body>
</html>

View File

@ -1,12 +1,12 @@
{% extends "page.jinja" %}
{% block sidebar %}
<div id="sidebar">
<nav>
{% if not template_sidebar_rows %}{% set template_sidebar_rows = [] %}{% endif %}
{% if not sidebar_rows %}{% set sidebar_rows = [] %}{% endif %}
<table>
{% for row in template_sidebar_rows + sidebar_rows %}
<tr><td>{{ row|safe }}</td></tr>{% endfor %}
</table>
</div>
</nav>
{% endblock %}
{% block content_class %}content-2col{% endblock %}

View File

@ -34,7 +34,7 @@
<body>
<div id="wrapper">
<div id="editor-left" class="column">
<div class="contentblock">
<section>
{# Thin header bar #}
<div id="editor-header">
{# Header always includes backlink to lexicon #}
@ -103,16 +103,16 @@
{# #}{{ article.contents }}{#
#}</textarea>
{% endif %}
</div>
</section>
</div>
<div id="editor-right" class="column">
<div id="preview" class="contentblock">
<section id="preview">
<p>This editor requires Javascript to function.</p>
</div>
<div id="preview-citations" class="contentblock">
<section id="preview-citations">
<p>&nbsp;</p>
</div>
<div id="preview-control" class="contentblock">
<section id="preview-control">
<p>&nbsp;</p>
</div>
</div>