Browse Source

Add basic wiki system for groups

This allows groups to have wiki pages. The rendered form of the page is
stored in the database, but the markdown source is kept on the
filesystem, using git to maintain the history (by doing a commit on
every edit).

A lot of aspects of this are still quite rough, but it should be a
decent start.
merge-requests/70/head
Deimos 6 years ago
parent
commit
bc4760871f
  1. 23
      salt/salt/tildes-wiki.sls
  2. 1
      salt/salt/top.sls
  3. 57
      tildes/alembic/versions/9b88cb0a7b2c_add_groupwikipage.py
  4. 1
      tildes/requirements-to-freeze.txt
  5. 1
      tildes/requirements.txt
  6. 1
      tildes/tildes/models/group/__init__.py
  7. 5
      tildes/tildes/models/group/group.py
  8. 134
      tildes/tildes/models/group/group_wiki_page.py
  9. 15
      tildes/tildes/resources/group.py
  10. 18
      tildes/tildes/routes.py
  11. 23
      tildes/tildes/schemas/group_wiki_page.py
  12. 34
      tildes/tildes/templates/group_wiki.jinja2
  13. 42
      tildes/tildes/templates/group_wiki_edit_page.jinja2
  14. 43
      tildes/tildes/templates/group_wiki_new_page.jinja2
  15. 44
      tildes/tildes/templates/group_wiki_page.jinja2
  16. 8
      tildes/tildes/templates/includes/wiki_editing_notes.jinja2
  17. 11
      tildes/tildes/templates/topic_listing.jinja2
  18. 102
      tildes/tildes/views/group_wiki_page.py
  19. 15
      tildes/tildes/views/topic.py

23
salt/salt/tildes-wiki.sls

@ -0,0 +1,23 @@
{% from 'common.jinja2' import app_username %}
# Create the base directory for the wiki files and initialize a git repo
/var/lib/tildes-wiki:
file.directory:
- user: {{ app_username }}
- group: {{ app_username }}
- mode: 775
git.present:
- bare: False
- user: {{ app_username }}
# Create the initial (empty) commit in the new git repo
wiki-initial-commit:
cmd.run:
- names:
- git config user.name "Tildes"
- git config user.email "Tildes"
- git commit --allow-empty -m "Initial commit"
- cwd: /var/lib/tildes-wiki
- runas: {{ app_username }}
- onchanges:
- file: /var/lib/tildes-wiki

1
salt/salt/top.sls

@ -19,6 +19,7 @@ base:
- prometheus.exporters.rabbitmq_exporter
- prometheus.exporters.redis_exporter
- consumers
- tildes-wiki
- boussole
- webassets
- cronjobs

57
tildes/alembic/versions/9b88cb0a7b2c_add_groupwikipage.py

@ -0,0 +1,57 @@
"""Add GroupWikiPage
Revision ID: 9b88cb0a7b2c
Revises: 53f81a72f076
Create Date: 2019-05-24 18:47:29.828223
"""
from alembic import op
import sqlalchemy as sa
from tildes.lib.database import CIText
# revision identifiers, used by Alembic.
revision = "9b88cb0a7b2c"
down_revision = "53f81a72f076"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"group_wiki_pages",
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("slug", CIText(), nullable=False),
sa.Column("page_name", sa.Text(), nullable=False),
sa.Column(
"created_time",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column("last_edited_time", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("rendered_html", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(
["group_id"],
["groups.group_id"],
name=op.f("fk_group_wiki_pages_group_id_groups"),
),
sa.PrimaryKeyConstraint("group_id", "slug", name=op.f("pk_group_wiki_pages")),
)
op.create_index(
op.f("ix_group_wiki_pages_last_edited_time"),
"group_wiki_pages",
["last_edited_time"],
unique=False,
)
op.create_check_constraint(
"page_name_length", "group_wiki_pages", "LENGTH(page_name) <= 40"
)
def downgrade():
op.drop_constraint("ck_group_wiki_pages_page_name_length", "group_wiki_pages")
op.drop_index(
op.f("ix_group_wiki_pages_last_edited_time"), table_name="group_wiki_pages"
)
op.drop_table("group_wiki_pages")

1
tildes/requirements-to-freeze.txt

@ -17,6 +17,7 @@ prometheus-client
prospector
psycopg2
publicsuffix2==2.20160818
pygit2
Pygments
pyotp
pyramid

1
tildes/requirements.txt

@ -55,6 +55,7 @@ pycodestyle==2.4.0
pycparser==2.19
pydocstyle==3.0.0
pyflakes==1.6.0
pygit2==0.28.1
Pygments==2.4.0
pylint==2.1.1
pylint-celery==0.3

1
tildes/tildes/models/group/__init__.py

@ -3,3 +3,4 @@
from .group import Group
from .group_query import GroupQuery
from .group_subscription import GroupSubscription
from .group_wiki_page import GroupWikiPage

5
tildes/tildes/models/group/group.py

@ -108,6 +108,11 @@ class Group(DatabaseModel):
acl.append((Allow, Authenticated, "post_topic"))
# wiki_page_create
# - permission must be granted specifically
acl.append((Allow, "admin", "wiki_page_create"))
acl.append((Allow, "wiki", "wiki_page_create"))
acl.append(DENY_ALL)
return acl

134
tildes/tildes/models/group/group_wiki_page.py

@ -0,0 +1,134 @@
# Copyright (c) 2019 Tildes contributors <code@tildes.net>
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Contains the GroupWikiPage class."""
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Sequence, Tuple
from pygit2 import Repository, Signature
from pyramid.security import Allow, DENY_ALL, Everyone
from sqlalchemy import CheckConstraint, Column, ForeignKey, Integer, Text, TIMESTAMP
from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import text
from tildes.lib.database import CIText
from tildes.lib.datetime import utc_now
from tildes.lib.markdown import convert_markdown_to_safe_html
from tildes.lib.string import convert_to_url_slug
from tildes.models import DatabaseModel
from tildes.models.user import User
from tildes.schemas.group_wiki_page import GroupWikiPageSchema, PAGE_NAME_MAX_LENGTH
from .group import Group
class GroupWikiPage(DatabaseModel):
"""Model for a wiki page in a group."""
schema_class = GroupWikiPageSchema
__tablename__ = "group_wiki_pages"
BASE_PATH = "/var/lib/tildes-wiki"
group_id: int = Column(
Integer, ForeignKey("groups.group_id"), nullable=False, primary_key=True
)
slug: str = Column(CIText, nullable=False, primary_key=True)
page_name: str = Column(
Text,
CheckConstraint(
f"LENGTH(page_name) <= {PAGE_NAME_MAX_LENGTH}", name="page_name_length"
),
nullable=False,
)
created_time: datetime = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=text("NOW()")
)
last_edited_time: Optional[datetime] = Column(TIMESTAMP(timezone=True), index=True)
rendered_html: str = Column(Text, nullable=False)
group: Group = relationship("Group", innerjoin=True, lazy=False)
def __init__(self, group: Group, page_name: str, markdown: str, user: User):
"""Create a new wiki page."""
self.group = group
self.page_name = page_name
self.slug = convert_to_url_slug(page_name)
# prevent possible conflict with url for creating a new page
if self.slug == "new_page":
raise ValueError("Invalid page name")
if self.file_path.exists():
raise ValueError("Wiki page already exists")
# create the directory for the group if it doesn't already exist
self.file_path.parent.mkdir(mode=0o755, exist_ok=True)
self.edit(markdown, user, f'~{group.path}: Create page "{page_name}"')
def __acl__(self) -> Sequence[Tuple[str, Any, str]]:
"""Pyramid security ACL."""
acl = []
# view:
# - all wiki pages can be viewed by everyone
acl.append((Allow, Everyone, "view"))
# edit:
# - permission must be granted specifically
acl.append((Allow, "admin", "edit"))
acl.append((Allow, "wiki", "edit"))
acl.append(DENY_ALL)
return acl
@property
def file_path(self) -> Path:
"""Return the full path to the page's file."""
return Path(self.BASE_PATH, str(self.group.path), f"{self.slug}.md")
@property
def markdown(self) -> Optional[str]:
"""Return the wiki page's markdown."""
try:
return self.file_path.read_text().rstrip("\r\n")
except FileNotFoundError:
return None
@markdown.setter
def markdown(self, new_markdown: str) -> None:
"""Write the wiki page's markdown to its file."""
# write the markdown to the file, appending a newline if necessary
if not new_markdown.endswith("\n"):
new_markdown = new_markdown + "\n"
self.file_path.write_text(new_markdown)
def edit(self, new_markdown: str, user: User, edit_message: str) -> None:
"""Set the page's markdown, render its HTML, and commit the repo."""
if new_markdown == self.markdown:
return
self.markdown = new_markdown
self.rendered_html = convert_markdown_to_safe_html(new_markdown)
self.last_edited_time = utc_now()
repo = Repository(self.BASE_PATH)
author = Signature(user.username, user.username)
repo.index.read()
repo.index.add(str(self.file_path.relative_to(self.BASE_PATH)))
repo.index.write()
repo.create_commit(
repo.head.name,
author,
author,
edit_message,
repo.index.write_tree(),
[repo.head.target],
)

15
tildes/tildes/resources/group.py

@ -3,12 +3,13 @@
"""Root factories for groups."""
from marshmallow.fields import String
from pyramid.httpexceptions import HTTPMovedPermanently
from pyramid.request import Request
from sqlalchemy_utils import Ltree
from webargs.pyramidparser import use_kwargs
from tildes.models.group import Group
from tildes.models.group import Group, GroupWikiPage
from tildes.resources import get_resource
from tildes.schemas.group import GroupSchema
@ -32,3 +33,15 @@ def group_by_path(request: Request, path: str) -> Group:
query = request.query(Group).filter(Group.path == Ltree(path))
return get_resource(request, query)
@use_kwargs({"wiki_page_slug": String()}, locations=("matchdict",))
def group_wiki_page_by_slug(request: Request, wiki_page_slug: str) -> GroupWikiPage:
"""Get a group's wiki page by its url slug (or 404)."""
group = group_by_path(request) # pylint: disable=no-value-for-parameter
query = request.query(GroupWikiPage).filter(
GroupWikiPage.group == group, GroupWikiPage.slug == wiki_page_slug
)
return get_resource(request, query)

18
tildes/tildes/routes.py

@ -10,7 +10,7 @@ from pyramid.request import Request
from pyramid.security import Allow, Authenticated
from tildes.resources.comment import comment_by_id36, notification_by_comment_id36
from tildes.resources.group import group_by_path
from tildes.resources.group import group_by_path, group_wiki_page_by_slug
from tildes.resources.message import message_conversation_by_id36
from tildes.resources.topic import topic_by_id36
from tildes.resources.user import user_by_username
@ -36,6 +36,22 @@ def includeme(config: Configurator) -> None:
config.add_route("group_topics", "/topics", factory=group_by_path)
config.add_route("group_wiki", "/wiki", factory=group_by_path)
# if you change this from "new_page" make sure to also edit
# GroupWikiPage.__init__() to block the new slug to avoid url conflicts
config.add_route("group_wiki_new_page", "/wiki/new_page", factory=group_by_path)
config.add_route(
"group_wiki_page", "/wiki/{wiki_page_slug}", factory=group_wiki_page_by_slug
)
config.add_route(
"group_wiki_edit_page",
"/wiki/{wiki_page_slug}/edit",
factory=group_wiki_page_by_slug,
)
# these routes need to remain last inside this block
config.add_route("topic", "/{topic_id36}/{title}", factory=topic_by_id36)
config.add_route("topic_no_title", "/{topic_id36}", factory=topic_by_id36)

23
tildes/tildes/schemas/group_wiki_page.py

@ -0,0 +1,23 @@
# Copyright (c) 2019 Tildes contributors <code@tildes.net>
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Validation/dumping schema for group wiki pages."""
from marshmallow import Schema
from tildes.schemas.fields import Markdown, SimpleString
PAGE_NAME_MAX_LENGTH = 40
class GroupWikiPageSchema(Schema):
"""Marshmallow schema for group wiki pages."""
page_name = SimpleString(max_length=PAGE_NAME_MAX_LENGTH)
markdown = Markdown(max_length=100_000)
class Meta:
"""Always use strict checking so error handlers are invoked."""
strict = True

34
tildes/tildes/templates/group_wiki.jinja2

@ -0,0 +1,34 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base_no_sidebar.jinja2' %}
{% from 'macros/datetime.jinja2' import adaptive_date_responsive %}
{% block title %}~{{ group.path }} wiki{% endblock %}
{% block header_context_link %}
<a class="site-header-context" href="/~{{ group.path }}">~{{ group.path }}</a>
{% endblock %}
{% block main_heading %}~{{ group.path }} wiki pages{% endblock %}
{% block content %}
{% if page_list %}
<ul>
{% for page in page_list %}
<li>
<a href="{{ request.route_url("group_wiki_page", group_path=group.path, wiki_page_slug=page.slug) }}">{{ page.page_name }}</a>
<div class="text-small text-secondary">Last edited: {{ adaptive_date_responsive(page.last_edited_time) }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>No pages yet.</p>
{% endif %}
{% if request.has_permission("wiki_page_create", group) %}
<hr>
<a href="{{ request.route_url("group_wiki_new_page", group_path=group.path) }}">Create new wiki page</a>
{% endif %}
{% endblock %}

42
tildes/tildes/templates/group_wiki_edit_page.jinja2

@ -0,0 +1,42 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base_no_sidebar.jinja2' %}
{% from 'macros/forms.jinja2' import markdown_textarea %}
{% block title %}Edit wiki page{% endblock %}
{% block header_context_link %}
<a class="site-header-context" href="/~{{ page.group.path }}">~{{ page.group.path }}</a>
{% endblock %}
{% block main_heading %}Editing page "{{ page.page_name }}" in ~{{ page.group.path }}{% endblock %}
{% block content %}
<form
method="post"
autocomplete="off"
action="/~{{ page.group.path }}/wiki/{{ page.slug }}"
data-ic-post-to="/~{{ page.group.path }}/wiki/{{ page.slug }}"
data-js-prevent-double-submit
data-js-confirm-leave-page-unsaved
>
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<div class="form-group">
{{ markdown_textarea(text=page.markdown, auto_focus=True) }}
</div>
<div class="form-group">
<label class="form-label" for="edit_message">Short edit summary</label>
<input class="form-input" id="edit_message" name="edit_message" type="text" placeholder="Edit summary" maxlength="80" required>
</div>
<div class="form-buttons">
<button type="submit" class="btn btn-primary">Save page</button>
</div>
{% include 'includes/wiki_editing_notes.jinja2' %}
</form>
{% endblock %}

43
tildes/tildes/templates/group_wiki_new_page.jinja2

@ -0,0 +1,43 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base_no_sidebar.jinja2' %}
{% from 'macros/forms.jinja2' import markdown_textarea %}
{% block title %}New wiki page{% endblock %}
{% block header_context_link %}
<a class="site-header-context" href="/~{{ group.path }}">~{{ group.path }}</a>
{% endblock %}
{% block main_heading %}Create a new wiki page in ~{{ group.path }}{% endblock %}
{% block content %}
<form
method="post"
autocomplete="off"
action="/~{{ group.path }}/wiki"
data-ic-post-to="/~{{ group.path }}/wiki"
data-js-prevent-double-submit
data-js-confirm-leave-page-unsaved
>
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<div class="form-group">
<label class="form-label" for="page_name">Page name</label>
<input class="form-input" id="page_name" name="page_name" type="text" placeholder="Page name" required data-js-auto-focus>
</div>
<div class="form-group">
{{ markdown_textarea() }}
</div>
<div class="form-buttons">
<button type="submit" class="btn btn-primary">Create wiki page</button>
</div>
{% include 'includes/wiki_editing_notes.jinja2' %}
</form>
</div>
{% endblock %}

44
tildes/tildes/templates/group_wiki_page.jinja2

@ -0,0 +1,44 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base.jinja2' %}
{% from 'macros/datetime.jinja2' import adaptive_date_responsive %}
{% block title %}{{ page.page_name }} - ~{{ page.group.path }} wiki{% endblock %}
{% block header_context_link %}
<a class="site-header-context" href="/~{{ page.group.path }}">~{{ page.group.path }}</a>
{% endblock %}
{% block main_heading %}{{ page.page_name }}{% endblock %}
{% block content %}
{{ page.rendered_html|safe }}
<hr>
<p class="text-small text-secondary">The text of this wiki page is licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">Creative Commons Attribution-ShareAlike 4.0</a>.</p>
<a href="{{ request.route_url("group_wiki", group_path=page.group.path) }}">Back to wiki page list</a>
{% endblock %}
{% block sidebar %}
<h2>Wiki page info</h2>
<dl>
<dt>Last edited</dt>
<dd>{{ adaptive_date_responsive(page.last_edited_time) }}</dd>
</dl>
{% if request.has_permission("edit", page) %}
<a href="{{ request.route_url("group_wiki_edit_page", group_path=page.group.path, wiki_page_slug=page.slug) }}" class="btn btn-primary">Edit this page</a>
{% endif %}
<ul class="nav">
<li>Page list</li>
<ul class="nav">
{% for other_page in page_list %}
<li class="nav-item"><a href="{{ request.route_url("group_wiki_page", group_path=other_page.group.path, wiki_page_slug=other_page.slug) }}">{{ other_page.page_name }}</a></li>
{% endfor %}
</ul>
</ul>
{% endblock %}

8
tildes/tildes/templates/includes/wiki_editing_notes.jinja2

@ -0,0 +1,8 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
<div class="toast toast-minor">
<h2>Important notes about editing wiki pages</h2>
<p>By submitting content to the Tildes wiki, you are agreeing to license it under <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">Creative Commons Attribution-ShareAlike 4.0</a> and understand that doing so means that the content can be copied, modified, and redistributed by others (as long as they follow the terms of that license).</p>
<p>The full history of Tildes wiki pages is retained and publicly available. Please be very careful not to include private information or other sensitive data, since it may be impossible to remove from the history.</p>
</div>

11
tildes/tildes/templates/topic_listing.jinja2

@ -220,6 +220,17 @@
<a href="/~{{ group.path }}/new_topic" class="btn btn-primary">Post a new topic</a>
{% endif %}
{% if wiki_pages %}
<ul class="nav">
<li>Group wiki pages</li>
<ul class="nav">
{% for page in wiki_pages %}
<li class="nav-item"><a href="{{ request.route_url("group_wiki_page", group_path=group.path, wiki_page_slug=page.slug) }}">{{ page.page_name }}</a></li>
{% endfor %}
</ul>
</ul>
{% endif %}
{% if request.user and not (tag or unfiltered) %}
<hr>
<details>

102
tildes/tildes/views/group_wiki_page.py

@ -0,0 +1,102 @@
# Copyright (c) 2019 Tildes contributors <code@tildes.net>
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Views related to group wiki pages."""
from pyramid.httpexceptions import HTTPFound
from pyramid.request import Request
from pyramid.view import view_config
from webargs.pyramidparser import use_kwargs
from tildes.models.group import GroupWikiPage
from tildes.schemas.fields import SimpleString
from tildes.schemas.group_wiki_page import GroupWikiPageSchema
@view_config(route_name="group_wiki", renderer="group_wiki.jinja2")
def get_group_wiki(request: Request) -> dict:
"""Get the group wiki page list."""
group = request.context
page_list = (
request.query(GroupWikiPage)
.filter(GroupWikiPage.group == group)
.order_by(GroupWikiPage.slug)
.all()
)
return {"group": group, "page_list": page_list}
@view_config(route_name="group_wiki_page", renderer="group_wiki_page.jinja2")
def get_group_wiki_page(request: Request) -> dict:
"""Display a single group wiki page."""
page = request.context
page_list = (
request.query(GroupWikiPage)
.filter(GroupWikiPage.group == page.group)
.order_by(GroupWikiPage.slug)
.all()
)
return {"page": page, "page_list": page_list}
@view_config(
route_name="group_wiki_new_page",
renderer="group_wiki_new_page.jinja2",
permission="wiki_page_create",
)
def get_wiki_new_page_form(request: Request) -> dict:
"""Form for entering a new wiki page to create."""
group = request.context
return {"group": group}
@view_config(
route_name="group_wiki", request_method="POST", permission="wiki_page_create"
)
@use_kwargs(GroupWikiPageSchema())
def post_group_wiki(request: Request, page_name: str, markdown: str) -> HTTPFound:
"""Create a new wiki page in a group."""
group = request.context
new_page = GroupWikiPage(group, page_name, markdown, request.user)
request.db_session.add(new_page)
raise HTTPFound(
location=request.route_url(
"group_wiki_page", group_path=group.path, wiki_page_slug=new_page.slug
)
)
@view_config(
route_name="group_wiki_edit_page",
renderer="group_wiki_edit_page.jinja2",
permission="edit",
)
def get_wiki_edit_page_form(request: Request) -> dict:
"""Form for editing an existing wiki page."""
page = request.context
return {"page": page}
@view_config(route_name="group_wiki_page", request_method="POST", permission="edit")
@use_kwargs(GroupWikiPageSchema(only=("markdown",)))
@use_kwargs({"edit_message": SimpleString(max_length=80)})
def post_group_wiki_page(request: Request, markdown: str, edit_message: str) -> dict:
"""Apply an edit to a single group wiki page."""
page = request.context
page.edit(markdown, request.user, edit_message)
raise HTTPFound(
location=request.route_url(
"group_wiki_page", group_path=page.group.path, wiki_page_slug=page.slug
)
)

15
tildes/tildes/views/topic.py

@ -27,7 +27,7 @@ from tildes.enums import (
from tildes.lib.database import ArrayOfLtree
from tildes.lib.datetime import SimpleHoursPeriod
from tildes.models.comment import Comment, CommentNotification, CommentTree
from tildes.models.group import Group
from tildes.models.group import Group, GroupWikiPage
from tildes.models.log import LogComment, LogTopic
from tildes.models.topic import Topic, TopicVisit
from tildes.models.user import UserGroupSettings
@ -106,7 +106,7 @@ def get_group_topics(
unfiltered: bool,
) -> dict:
"""Get a listing of topics in the group."""
# pylint: disable=too-many-arguments
# pylint: disable=too-many-arguments, too-many-branches
if request.matched_route.name == "home":
# on the home page, include topics from the user's subscribed groups
# (or all groups, if logged-out)
@ -169,6 +169,16 @@ def get_group_topics(
if period and period not in period_options:
period_options.append(period)
if isinstance(request.context, Group):
wiki_pages = (
request.query(GroupWikiPage)
.filter(GroupWikiPage.group == request.context)
.order_by(GroupWikiPage.slug)
.all()
)
else:
wiki_pages = None
return {
"group": request.context,
"groups": groups,
@ -184,6 +194,7 @@ def get_group_topics(
"rank_start": rank_start,
"tag": tag,
"unfiltered": unfiltered,
"wiki_pages": wiki_pages,
}

Loading…
Cancel
Save