mirror of https://gitlab.com/tildes/tildes.git
Browse Source
Add basic wiki system for groups
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
19 changed files with 574 additions and 4 deletions
-
23salt/salt/tildes-wiki.sls
-
1salt/salt/top.sls
-
57tildes/alembic/versions/9b88cb0a7b2c_add_groupwikipage.py
-
1tildes/requirements-to-freeze.txt
-
1tildes/requirements.txt
-
1tildes/tildes/models/group/__init__.py
-
5tildes/tildes/models/group/group.py
-
134tildes/tildes/models/group/group_wiki_page.py
-
15tildes/tildes/resources/group.py
-
18tildes/tildes/routes.py
-
23tildes/tildes/schemas/group_wiki_page.py
-
34tildes/tildes/templates/group_wiki.jinja2
-
42tildes/tildes/templates/group_wiki_edit_page.jinja2
-
43tildes/tildes/templates/group_wiki_new_page.jinja2
-
44tildes/tildes/templates/group_wiki_page.jinja2
-
8tildes/tildes/templates/includes/wiki_editing_notes.jinja2
-
11tildes/tildes/templates/topic_listing.jinja2
-
102tildes/tildes/views/group_wiki_page.py
-
15tildes/tildes/views/topic.py
@ -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 |
@ -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") |
@ -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], |
|||
) |
@ -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 |
@ -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 %} |
@ -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 %} |
@ -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 %} |
@ -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 %} |
@ -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> |
@ -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 |
|||
) |
|||
) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue