Page data moved to db. Prep for access control

This commit is contained in:
Dirk Alders 2024-10-21 07:20:37 +02:00
parent 292631ed21
commit a528ac19cb
20 changed files with 683 additions and 356 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
# piki
data/media
data/pages
data/mycreole
data/static
data/whoosh
db.sqlite3

View File

@ -1,5 +0,0 @@
{
"creation_time": 1728465254,
"modified_time": 1728465254,
"modified_user": "system-page"
}

View File

@ -1,2 +0,0 @@
= Index
<<allpages>>

View File

@ -1,5 +0,0 @@
{
"creation_time": 1728465495,
"modified_time": 1728649989,
"modified_user": "system-page"
}

View File

@ -1,5 +0,0 @@
= Default startpage
Edit this page to get your own first startpage.
If you need need assistance to edit a page, visit the [[/helpview/main|help pages]].

View File

@ -1,5 +0,0 @@
{
"modified_time": 1729022206,
"modified_user": "system-page",
"creation_time": 1729022206
}

View File

@ -1,2 +0,0 @@
= Tree
<<allpagestree>>

View File

@ -1,16 +1,27 @@
def read_page(request, rel_path):
return "private" not in rel_path or write_page(request, rel_path)
class access_control(object):
def __init__(self, request, rel_path):
self._request = request
self._rel_path = rel_path
def may_read(self):
return "private" not in self._rel_path or self.may_write()
def write_page(request, rel_path):
return request.user.is_authenticated and request.user.username in ['root', 'dirk']
def may_write(self):
# /!\ rel_path is the filsystem rel_path - caused by the flat folder structure /!\
return self._request.user.is_authenticated and self._request.user.username in ['root', 'dirk']
def may_read_attachment(self):
return self.may_read()
def may_modify_attachment(self):
return self.may_write()
def read_attachment(request, rel_path):
# /!\ rel_path is the filsystem rel_path - caused by the flat folder structure /!\
return True
# Interface for external module mycreole
return access_control(request, rel_path).may_read_attachment()
def modify_attachment(request, rel_path):
# /!\ rel_path is the filsystem rel_path - caused by the flat folder structure /!\
return request.user.is_authenticated and request.user.username in ['root', 'dirk']
# Interface for external module mycreole
return access_control(request, rel_path).may_modify_attachment()

View File

@ -1,3 +1,17 @@
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
# Register your models here.
from .models import PikiPage
class PikiPageAdmin(SimpleHistoryAdmin):
list_display = ('rel_path', 'tags', 'deleted')
history_list_display = ('rel_path', 'tags', 'deleted')
search_fields = ('rel_path', 'tags', )
list_filter = (
('deleted', admin.BooleanFieldListFilter),
)
ordering = ["rel_path"]
admin.site.register(PikiPage, PikiPageAdmin)

View File

@ -5,7 +5,7 @@ import os
from django.conf import settings
from django.utils.translation import gettext as _
from pages import access
from pages.access import access_control
import pages.parameter
from .help import actionbar as actionbar_add_help
import mycreole
@ -74,11 +74,12 @@ def actionbar(context, request, caller_name, **kwargs):
bar = context[context.ACTIONBAR]
if not cms_mode_active(request):
if caller_name in ['page', 'edit', 'delete', 'rename']:
if access.write_page(request, kwargs["rel_path"]):
acc = access_control(request, kwargs["rel_path"])
if acc.may_write():
add_page_menu(request, bar, kwargs["rel_path"], kwargs.get('is_available', False))
if access.modify_attachment(request, kwargs["rel_path"]):
if acc.may_modify_attachment():
add_manageupload_menu(request, bar, kwargs['upload_path'], kwargs.get('is_available', False))
if access.read_page(request, kwargs["rel_path"]):
if acc.may_read():
add_meta_menu(request, bar, kwargs["rel_path"], kwargs.get('is_available', False))
elif caller_name == 'helpview':
actionbar_add_help(context, request, **kwargs)

View File

@ -3,17 +3,13 @@ from django import forms
from django.forms.renderers import BaseRenderer
from django.forms.utils import ErrorList
from .models import PikiPage
class EditForm(forms.Form): # Note that it is not inheriting from forms.ModelForm
page_txt = forms.CharField(max_length=20000, label="Page source text", widget=forms.Textarea(attrs={"rows": "20"}))
page_tags = forms.CharField(max_length=500, label="Tags (words separated by spaces)", required=False)
def __init__(self, *args, **kwargs) -> None:
page_data = kwargs.pop("page_data")
page_tags = kwargs.pop("page_tags")
super().__init__(*args, **kwargs)
self.fields['page_txt'].initial = page_data
self.fields['page_tags'].initial = page_tags
class EditForm(forms.ModelForm):
class Meta:
model = PikiPage
fields = ["page_txt", "tags", "owner", "group"]
class RenameForm(forms.Form): # Note that it is not inheriting from forms.ModelForm

View File

@ -0,0 +1,57 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from pages.models import PikiPage
from datetime import datetime
import fstools
from zoneinfo import ZoneInfo
SYSTEM_PAGES = {
"tree": """= Tree
<<allpagestree>>""",
"index": """= Index
<<allpages>>""",
}
def add_page_data(rel_path, tags, page_txt, creation_time, creation_user, modified_time, modified_user):
try:
page = PikiPage.objects.get(rel_path=rel_path)
except PikiPage.DoesNotExist:
page = PikiPage(rel_path=rel_path)
#
page.tags = tags
page.page_txt = page_txt
#
page.creation_time = creation_time
try:
page.creation_user = User.objects.get(username=creation_user)
except User.DoesNotExist:
page.creation_user = None
page.modified_time = modified_time
try:
page.modified_user = User.objects.get(username=modified_user)
except User.DoesNotExist:
page.modified_user = None
#
page.save()
class Command(BaseCommand):
def handle(self, *args, **options):
for rel_path in SYSTEM_PAGES:
self.stdout.write(self.style.MIGRATE_HEADING("Migration of page '%s'" % rel_path))
#
dtm = datetime.now(ZoneInfo("UTC"))
add_page_data(
rel_path,
"",
SYSTEM_PAGES[rel_path],
dtm,
None,
dtm,
None
)

View File

@ -0,0 +1,73 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from pages.page import full_path_all_pages, page_wrapped
from pages.models import PikiPage
from datetime import datetime
import fstools
import os
import shutil
from zoneinfo import ZoneInfo
def add_page_data(rel_path, tags, page_txt, creation_time, creation_user, modified_time, modified_user):
try:
page = PikiPage.objects.get(rel_path=rel_path)
except PikiPage.DoesNotExist:
page = PikiPage(rel_path=rel_path)
#
page.tags = tags
page.page_txt = page_txt
#
page.creation_time = datetime.fromtimestamp(creation_time, ZoneInfo("UTC"))
creation_user = creation_user or "dirk"
page.creation_user = User.objects.get(username=creation_user)
modified_user = modified_user or "dirk"
page.modified_time = datetime.fromtimestamp(modified_time, ZoneInfo("UTC"))
page.modified_user = User.objects.get(username=modified_user)
page.owner = page.owner or page.creation_user
#
page.save()
class Command(BaseCommand):
def handle(self, *args, **options):
for path in full_path_all_pages():
fs_page = page_wrapped(None, path)
if fs_page._page.is_available():
self.stdout.write(self.style.MIGRATE_HEADING("Migration of page '%s'" % fs_page.rel_path))
for history_number in fs_page._page.history_numbers_list():
self.stdout.write(self.style.MIGRATE_HEADING(" * Adding history version %d" % history_number))
h_page = page_wrapped(None, path, history_version=history_number)
add_page_data(
rel_path=h_page.rel_path,
tags=h_page.tags,
page_txt=h_page._page.raw_page_src,
#
creation_time=h_page.creation_time,
creation_user=h_page.creation_user,
modified_time=h_page.modified_time,
modified_user=h_page.modified_user
)
#
self.stdout.write(self.style.MIGRATE_HEADING(" * Adding current version"))
add_page_data(
rel_path=fs_page.rel_path,
tags=fs_page.tags,
page_txt=fs_page._page.raw_page_src,
#
creation_time=fs_page.creation_time,
creation_user=fs_page.creation_user,
modified_time=fs_page.modified_time,
modified_user=fs_page.modified_user
)
#
src = os.path.join(path, "attachments")
if os.path.isdir(src):
dst = os.path.join(settings.MYCREOLE_ROOT, fs_page.rel_path)
for attachment in fstools.filelist(src):
self.stdout.write(self.style.MIGRATE_HEADING(" * Copy attachment ''%s to new location" % os.path.basename(attachment)))
fstools.mkdir(dst)
shutil.copy(attachment, dst)

View File

@ -8,6 +8,11 @@ def permission_denied_msg_page(request, rel_path):
messages.error(request, _("Permission denied: You don't have sufficient acces to the Page '%s'. Please contact the adminstrator.") % rel_path)
def deleted_page(request):
# TODO: Add translation for this message
messages.info(request, _("Page was deleted. Recover not yet implemented. Use the 'Administration' area for recovery. Rebuild the search index afterwards."))
def unavailable_msg_page(request, rel_path):
# TODO: Add translation for this message
messages.info(request, _("Unavailable: The Page '%s' is not available. Create it or follow a valid link, please.") % rel_path)
@ -38,6 +43,11 @@ def page_renamed(request):
messages.info(request, _('The page has been renamed.'))
def internal_error(request):
# TODO: Add translation for this message
messages.error(request, _('internal ERROR: Action not performed..'))
def history_version_display(request, rel_path, history_version):
# TODO: Add translation for this message
messages.warning(request, _("You see an old version of the page (Version = %d). Click <a href='%s'>here</a> to recover this Version.") % (

View File

@ -0,0 +1,63 @@
# Generated by Django 5.1.2 on 2024-10-21 04:20
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='HistoricalPikiPage',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('rel_path', models.CharField(db_index=True, max_length=1000)),
('page_txt', models.TextField(max_length=50000)),
('tags', models.CharField(blank=True, max_length=1000, null=True)),
('deleted', models.BooleanField(default=False)),
('creation_time', models.DateTimeField(blank=True, null=True)),
('modified_time', models.DateTimeField(blank=True, null=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('creation_user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='auth.group')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('modified_user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical piki page',
'verbose_name_plural': 'historical piki pages',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='PikiPage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rel_path', models.CharField(max_length=1000, unique=True)),
('page_txt', models.TextField(max_length=50000)),
('tags', models.CharField(blank=True, max_length=1000, null=True)),
('deleted', models.BooleanField(default=False)),
('creation_time', models.DateTimeField(blank=True, null=True)),
('modified_time', models.DateTimeField(blank=True, null=True)),
('creation_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='creation_user', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='group', to='auth.group')),
('modified_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_user', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owner', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,3 +1,284 @@
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.db import models
from django.utils.translation import gettext as _
from simple_history.models import HistoricalRecords
# Create your models here.
from datetime import datetime
import difflib
import logging
import os
from zoneinfo import ZoneInfo
from users.models import get_userprofile
from pages import url_page
import mycreole
logger = logging.getLogger(settings.ROOT_LOGGER_NAME).getChild(__name__)
class PikiPage(models.Model):
SAVE_ON_CHANGE_FIELDS = ["rel_path", "page_txt", "tags", "deleted", "owner", "group"]
#
rel_path = models.CharField(unique=True, max_length=1000)
page_txt = models.TextField(max_length=50000)
tags = models.CharField(max_length=1000, null=True, blank=True)
deleted = models.BooleanField(default=False)
#
creation_time = models.DateTimeField(null=True, blank=True)
creation_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="creation_user")
modified_time = models.DateTimeField(null=True, blank=True)
modified_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="modified_user")
#
owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="owner")
group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL, related_name="group")
# owner_perms
# group_perms
# other_perms
#
history = HistoricalRecords()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def prepare_save(self, request):
# Set date
tmd = datetime.now(tz=ZoneInfo("UTC")).replace(microsecond=0)
self.creation_time = self.creation_time or tmd
self.modified_time = tmd
# Set user
self.creation_user = self.creation_user or request.user
self.owner = self.owner or request.user
self.modified_user = request.user
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
if self.id and not force_update:
orig = PikiPage.objects.get(id=self.id)
for key in self.SAVE_ON_CHANGE_FIELDS:
if getattr(self, key) != getattr(orig, key):
break
else:
self.save_needed = False
return False
self.save_needed = True
return models.Model.save(self, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
#
# Set history datetime to modified datetime
#
@property
def _history_date(self):
return self.modified_time
@_history_date.setter
def _history_date(self, value):
self.modified_time = value
#
# My information
#
@property
def title(self):
return self.rel_path.split("/")[-1]
#
# My methods
#
def render_to_html(self, request, history=None):
if history:
h = self.history.get(history_id=history)
return self.render_text(request, h.page_txt)
else:
return self.render_text(request, self.page_txt)
def user_datetime(self, request, dtm):
try:
up = get_userprofile(request.user)
except AttributeError:
tz = ZoneInfo("UTC")
else:
tz = ZoneInfo(up.timezone)
#
return datetime.astimezone(dtm, tz)
def render_meta(self, request, history):
# Page information
meta = f'= {_("Meta data")}\n'
meta += f'|{_("Created by")}:|{self.creation_user}|\n'
meta += f'|{_("Created at")}:|{self.user_datetime(request, self.creation_time)}|\n'
meta += f'|{_("Modified by")}:|{self.modified_user}|\n'
meta += f'|{_("Modified at")}:|{self.user_datetime(request, self.modified_time)}|\n'
meta += f'|{_("Owner")}:|{self.owner or "---"}|\n'
meta += f'|{_("Group")}:|{self.group or "---"}|\n'
meta += f'|{_("Tags")}|{self.tags or "---"}|\n'
#
# List of history page versions
#
hl = self.history.all()[1:]
if len(hl) > 0:
meta += f'= {_("History")}\n'
meta += f'| ={_("Version")} | ={_("Date")} | ={_("Page")} | ={_("Meta data")} | ={_("Page changed")} | ={_("Tags changed")} | \n'
# Current
name = _("Current")
meta += f"| {name} \
| {self.user_datetime(request, self.modified_time)} \
| [[{url_page(self.rel_path)} | Page]] \
| [[{url_page(self.rel_path, meta=None)} | Meta]] |"
page_content = self.page_txt.replace("\r\n", "\n").strip("\n")
tags = self.tags
for h_page in hl:
page_changed = page_content != h_page.page_txt.replace("\r\n", "\n").strip("\n")
tags_changed = tags != h_page.tags
if page_changed or tags_changed:
meta += " %s |" % ("Yes" if page_changed else "No")
meta += " %s |" % ("Yes" if tags_changed else "No")
meta += "\n"
meta += f"| {h_page.history_id} \
| {self.user_datetime(request, h_page.modified_time)} \
| [[{url_page(self.rel_path, history=h_page.history_id)} | Page]] \
| [[{url_page(self.rel_path, meta=None, history=h_page.history_id)} | Meta]] (with diff to current) |"
page_content = h_page.page_txt[:].replace("\r\n", "\n").strip("\n")
tags = h_page.tags
meta += " --- | --- |\n"
# Diff
html_diff = ""
if history:
h_page = self.history.get(history_id=history)
#
meta += f'= {_("Page differences")}\n'
#
left_lines = self.page_txt.splitlines()
right_lines = h_page.page_txt.splitlines()
html_diff = difflib.HtmlDiff(wrapcolumn=80).make_table(left_lines, right_lines, "Current page", "Page Version %d" % history)
#
return mycreole.render_simple(meta) + html_diff
#
# Creole stuff
#
def render_text(self, request, txt):
macros = {
"subpages": self.macro_subpages,
"allpages": self.macro_allpages,
"subpagetree": self.macro_subpagetree,
"allpagestree": self.macro_allpagestree,
}
return mycreole.render(request, txt, self.rel_path, macros=macros)
def macro_subpages(self, *args, **kwargs):
return self.macro_pages(*args, **kwargs)
def macro_allpages(self, *args, **kwargs):
kwargs["allpages"] = True
return self.macro_pages(*args, **kwargs)
def macro_allpagestree(self, *args, **kwargs):
kwargs["allpages"] = True
kwargs["tree"] = True
return self.macro_pages(*args, **kwargs)
def macro_subpagetree(self, * args, **kwargs):
kwargs["tree"] = True
return self.macro_pages(*args, **kwargs)
def macro_pages(self, *args, **kwargs):
allpages = kwargs.pop("allpages", False)
tree = kwargs.pop("tree", False)
#
def parse_depth(s: str):
try:
return int(s)
except ValueError:
pass
params = kwargs.get('', '')
filter_str = ''
depth = parse_depth(params)
if depth is None:
params = params.split(",")
depth = parse_depth(params[0])
if len(params) == 2:
filter_str = params[1]
elif depth is None:
filter_str = params[0]
#
if not allpages:
filter_str = os.path.join(self.rel_path, filter_str)
#
pages = PikiPage.objects.filter(rel_path__contains=filter_str)
pl = page_list([p for p in pages if not p.deleted])
#
if tree:
return "<pre>\n" + page_tree(pl).html() + "</pre>\n"
else:
return pl.html_list(depth=depth, filter_str=filter_str, parent_rel_path='' if allpages else self.rel_path)
class page_list(list):
def __init__(self, *args, **kwargs):
return super().__init__(*args, **kwargs)
def sort_basename(self):
return list.sort(self, key=lambda x: os.path.basename(x.rel_path))
def creole_list(self, depth=None, filter_str='', parent_rel_path=''):
self.sort_basename()
depth = depth or 9999 # set a random high value if None
#
rv = ""
last_char = None
for page in self:
if page.rel_path.startswith(filter_str) and page.rel_path != filter_str:
name = page.rel_path[len(parent_rel_path):].lstrip("/")
if name.count('/') < depth:
first_char = os.path.basename(name)[0].upper()
if last_char != first_char:
last_char = first_char
rv += f"=== {first_char}\n"
rv += f"* [[{url_page(page.rel_path)} | {name} ]]\n"
return rv
def html_list(self, depth=9999, filter_str='', parent_rel_path=''):
return mycreole.render_simple(self.creole_list(depth, filter_str, parent_rel_path))
class page_tree(dict):
T_PATTERN = "├── "
L_PATTERN = "└── "
I_PATTERN = ""
D_PATTERN = " "
def __init__(self, pl: page_list):
super().__init__()
for page in pl:
store_item = self
for entry in page.rel_path.split("/"):
if not entry in store_item:
store_item[entry] = {}
store_item = store_item[entry]
def html(self, rel_path=None, fill=""):
base = self
try:
for key in rel_path.split("/"):
base = base[key]
except AttributeError:
rel_path = ''
#
rv = ""
#
l = len(base)
for entry in sorted(list(base.keys())):
l -= 1
page_path = os.path.join(rel_path, entry)
try:
PikiPage.objects.get(rel_path=page_path)
except PikiPage.DoesNotExist:
pass
else:
entry = f'<a href="{url_page(page_path)}">{entry}</a>'
rv += fill + (self.L_PATTERN if l == 0 else self.T_PATTERN) + entry + "\n"
rv += self.html(page_path, fill=fill+(self.D_PATTERN if l == 0 else self.I_PATTERN))
return rv

View File

@ -30,22 +30,27 @@ def full_path_all_pages(expression="*"):
return rv
def full_path_deleted_pages(expression="*"):
system_pages = fstools.dirlist(settings.SYSTEM_PAGES_ROOT, expression=expression, rekursive=False)
system_pages = [os.path.join(settings.PAGES_ROOT, os.path.basename(path)) for path in system_pages]
pages = fstools.dirlist(settings.PAGES_ROOT, expression=expression, rekursive=False)
rv = []
for path in set(system_pages + pages):
p = page_wrapped(None, path)
if not p.is_available():
rv.append(path)
return rv
class base(dict):
@property
def rel_path(self):
return os.path.basename(self._path).replace(2*SPLITCHAR, "/")
def is_available(self):
is_a = os.path.isfile(self.filename)
if not is_a:
logger.debug("Not available - %s", self.filename)
return is_a
def history_numbers_list(self):
history_folder = os.path.join(self._path, HISTORY_FOLDER_NAME)
return list(set([int(os.path.basename(filename)[:5]) for filename in fstools.filelist(history_folder)]))
class meta_data(dict):
class meta_data(base):
META_FILE_NAME = 'meta.json'
#
KEY_CREATION_TIME = "creation_time"
KEY_CREATION_USER = "creation_user"
KEY_MODIFIED_TIME = "modified_time"
KEY_MODIFIED_USER = "modified_user"
KEY_TAGS = "tags"
@ -85,6 +90,9 @@ class meta_data(dict):
if username:
self[self.KEY_MODIFIED_TIME] = int(time.time())
self[self.KEY_MODIFIED_USER] = username
#
if self.KEY_CREATION_USER not in self:
self[self.KEY_CREATION_USER] = self[self.KEY_MODIFIED_USER]
if self.KEY_CREATION_TIME not in self:
self[self.KEY_CREATION_TIME] = self[self.KEY_MODIFIED_TIME]
if tags:
@ -109,7 +117,7 @@ class meta_data(dict):
shutil.copy(self.filename, history_filename)
class page_data(object):
class page_data(base):
PAGE_FILE_NAME = 'page'
def __init__(self, path, history_version=None):
@ -168,12 +176,6 @@ class page_data(object):
def rel_path(self):
return os.path.basename(self._path).replace(2*SPLITCHAR, "/")
def is_available(self):
is_a = os.path.isfile(self.filename)
if not is_a:
logger.debug("page.is_available: Not available - %s", self.filename)
return is_a
@property
def title(self):
return os.path.basename(self._path).split(2*SPLITCHAR)[-1]
@ -189,200 +191,6 @@ class page_data(object):
shutil.copy(self.filename, history_filename)
class page_django(page_data):
FOLDER_ATTACHMENTS = "attachments"
def __init__(self, request, path, history_version=None) -> None:
self._request = request
super().__init__(path, history_version=history_version)
@property
def attachment_path(self):
return os.path.join(os.path.basename(self._path), self.FOLDER_ATTACHMENTS)
def render_to_html(self):
if self.is_available():
return self.render_text(self._request, self.raw_page_src)
else:
messages.unavailable_msg_page(self._request, self.rel_path)
return ""
def history_numbers_list(self):
history_folder = os.path.join(self._path, HISTORY_FOLDER_NAME)
return list(set([int(os.path.basename(filename)[:5]) for filename in fstools.filelist(history_folder)]))
def render_meta(self, ctime, mtime, user, tags):
#
# Page meta data
#
meta = f'=== {_("Meta data")}\n'
meta += f'|{_("Created")}:|{timestamp_to_datetime(self._request, ctime)}|\n'
meta += f'|{_("Modified")}:|{timestamp_to_datetime(self._request, mtime)}|\n'
meta += f'|{_("Editor")}|{user}|\n'
meta += f'|{_("Tags")}|{tags}|\n'
#
# List of hostory page versions
#
hnl = self.history_numbers_list()
if hnl:
meta += f'=== {_("History")}\n'
meta += f'| ={_("Version")} | ={_("Date")} | ={_("Page")} | ={_("Meta data")} | \n'
# Current
name = _("Current")
meta += f"| {name} \
| {timestamp_to_datetime(self._request, mtime)} \
| [[{url_page(self.rel_path)} | Page]] \
| [[{url_page(self.rel_path, meta=None)} | Meta]]\n"
# History
for num in reversed(hnl):
p = page_wrapped(self._request, self._path, history_version=num)
meta += f"| {num} \
| {timestamp_to_datetime(self._request, p.modified_time)} \
| [[{url_page(p.rel_path, history=num)} | Page]] \
| [[{url_page(p.rel_path, meta=None, history=num)} | Meta]] (with page changes)\n"
# Diff
html_diff = ""
if self._history_version:
meta += f'=== {_("Page differences")}\n'
#
c = page_django(self._request, self._path)
left_lines = c.raw_page_src.splitlines()
right_lines = self.raw_page_src.splitlines()
html_diff = difflib.HtmlDiff(wrapcolumn=80).make_table(left_lines, right_lines)
#
return mycreole.render_simple(meta) + html_diff
def render_text(self, request, txt):
macros = {
"subpages": self.macro_subpages,
"allpages": self.macro_allpages,
"subpagetree": self.macro_subpagetree,
"allpagestree": self.macro_allpagestree,
}
return mycreole.render(request, txt, self.attachment_path, macros=macros)
def macro_allpages(self, *args, **kwargs):
kwargs["allpages"] = True
return self.macro_subpages(*args, **kwargs)
def macro_subpages(self, *args, **kwargs):
allpages = kwargs.pop("allpages", False)
tree = kwargs.pop("tree", False)
#
def parse_depth(s: str):
try:
return int(s)
except ValueError:
pass
params = kwargs.get('', '')
filter_str = ''
depth = parse_depth(params)
if depth is None:
params = params.split(",")
depth = parse_depth(params[0])
if len(params) == 2:
filter_str = params[1]
elif depth is None:
filter_str = params[0]
#
rv = ""
# create a page_list
if allpages:
expression = "*"
parent_rel_path = ""
else:
expression = os.path.basename(self._path) + 2 * SPLITCHAR + "*"
parent_rel_path = self.rel_path
#
pl = page_list(
self._request,
[page_django(self._request, path) for path in full_path_all_pages(expression)]
)
if tree:
return "<pre>\n" + page_tree(pl).html() + "</pre>\n"
else:
return pl.html_list(depth=depth, filter_str=filter_str, parent_rel_path=parent_rel_path)
def macro_allpagestree(self, *args, **kwargs):
kwargs["allpages"] = True
kwargs["tree"] = True
return self.macro_subpages(*args, **kwargs)
def macro_subpagetree(self, * args, **kwargs):
kwargs["tree"] = True
return self.macro_subpages(*args, **kwargs)
class page_list(list):
def __init__(self, request, *args, **kwargs):
self._request = request
return super().__init__(*args, **kwargs)
def sort_basename(self):
return list.sort(self, key=lambda x: os.path.basename(x.rel_path))
def creole_list(self, depth=None, filter_str='', parent_rel_path=''):
self.sort_basename()
depth = depth or 9999 # set a random high value if None
#
parent_rel_path = parent_rel_path + "/" if len(parent_rel_path) > 0 else ""
#
rv = ""
last_char = None
for page in self:
name = page.rel_path[len(parent_rel_path):]
if name.startswith(filter_str) and name != filter_str:
if name.count('/') < depth:
first_char = os.path.basename(name)[0].upper()
if last_char != first_char:
last_char = first_char
rv += f"=== {first_char}\n"
rv += f"* [[{url_page(page.rel_path)} | {name} ]]\n"
return rv
def html_list(self, depth=9999, filter_str='', parent_rel_path=''):
return mycreole.render_simple(self.creole_list(depth, filter_str, parent_rel_path))
class page_tree(dict):
T_PATTERN = "├── "
L_PATTERN = "└── "
I_PATTERN = ""
D_PATTERN = " "
def __init__(self, pl: page_list):
super().__init__()
for page in pl:
store_item = self
for entry in page.rel_path.split("/"):
if not entry in store_item:
store_item[entry] = {}
store_item = store_item[entry]
def html(self, rel_path=None, fill=""):
base = self
try:
for key in rel_path.split("/"):
base = base[key]
except AttributeError:
rel_path = ''
#
rv = ""
#
l = len(base)
for entry in sorted(list(base.keys())):
l -= 1
page_path = os.path.join(rel_path, entry)
page = page_wrapped(None, page_path)
if page.is_available():
entry = f'<a href="{url_page(page_path)}">{entry}</a>'
rv += fill + (self.L_PATTERN if l == 0 else self.T_PATTERN) + entry + "\n"
rv += self.html(page_path, fill=fill+(self.D_PATTERN if l == 0 else self.I_PATTERN))
return rv
class page_wrapped(object):
"""
This class holds different page and meta instances and decides which will be used in which case.
@ -399,19 +207,9 @@ class page_wrapped(object):
self._request = request
#
page_path = self.__page_path__(path)
system_page_path = self.__system_page_path__(path)
# Page
if request:
self._page = page_django(request, page_path, history_version=history_version)
else:
self._page = page_data(page_path, history_version=history_version)
self._page = page_data(page_path, history_version=history_version)
self._page_meta = meta_data(page_path, history_version=history_version)
# System page
if request:
self._system_page = page_django(request, system_page_path)
else:
self._system_page = page_data(system_page_path)
self._system_meta_data = meta_data(system_page_path)
def __page_path__(self, path):
if path.startswith(settings.PAGES_ROOT):
@ -421,20 +219,11 @@ class page_wrapped(object):
# must be a relative url
return os.path.join(settings.PAGES_ROOT, path.replace("/", 2*SPLITCHAR))
def __system_page_path__(self, path):
return os.path.join(settings.SYSTEM_PAGES_ROOT, os.path.basename(path))
def __page_choose__(self):
if not self._page.is_available():
return self._system_page
else:
return self._page
return self._page
def __meta_choose__(self):
if not self._page.is_available():
return self._system_meta_data
else:
return self._page_meta
return self._page_meta
def __store_history__(self):
if self._page.is_available():
@ -454,6 +243,12 @@ class page_wrapped(object):
rv = meta.get(meta.KEY_CREATION_TIME)
return rv
@property
def creation_user(self):
meta = self.__meta_choose__()
rv = meta.get(meta.KEY_CREATION_USER)
return rv
def delete(self):
self.__store_history__()
self._page.delete()
@ -490,7 +285,7 @@ class page_wrapped(object):
return rv
def is_available(self):
return self._page.is_available() or self._system_page.is_available()
return self._page.is_available()
def userpage_is_available(self):
return self._page.is_available()
@ -509,7 +304,7 @@ class page_wrapped(object):
def render_meta(self):
page = self.__page_choose__()
rv = page.render_meta(self.creation_time, self.modified_time, self.modified_user, self.tags)
rv = page.render_meta(self.creation_time, self.modified_time, self.creation_user, self.modified_user, self.tags)
return rv
def render_to_html(self):

View File

@ -8,7 +8,7 @@ from whoosh.fields import Schema, ID, TEXT, DATETIME
from whoosh.qparser.dateparse import DateParserPlugin
from whoosh import index, qparser
from pages.page import page_wrapped, full_path_all_pages
from .models import PikiPage
logger = logging.getLogger(settings.ROOT_LOGGER_NAME).getChild(__name__)
@ -38,11 +38,11 @@ def create_index():
def rebuild_index(ix):
page_path = full_path_all_pages()
for path in page_path:
pw = page_wrapped(None, path)
add_item(ix, pw)
return len(page_path)
pages = PikiPage.objects.all()
for pp in pages:
if not pp.deleted:
add_item(ix, pp)
return len(pages)
def load_index():
@ -56,19 +56,19 @@ def load_index():
return ix
def add_item(ix, pw: page_wrapped):
def add_item(ix, pp: PikiPage):
# Define Standard data
#
data = dict(
id=pw.rel_path,
id=pp.rel_path,
#
title=pw.title,
page_src=pw.raw_page_src,
tag=pw.tags,
title=pp.title,
page_src=pp.page_txt,
tag=pp.tags,
#
creation_time=datetime.fromtimestamp(pw.creation_time),
modified_time=datetime.fromtimestamp(pw.modified_time),
modified_user=pw.modified_user
creation_time=pp.creation_time,
modified_time=pp.modified_time,
modified_user=None if pp.modified_user is None else pp.modified_user.username
)
with ix.writer() as w:
logger.info('Adding document with id=%s to the search index.', data.get('id'))
@ -95,13 +95,13 @@ def whoosh_search(search_txt):
return rpl
def delete_item(ix, pw: page_wrapped):
def delete_item(ix, pp: PikiPage):
with ix.writer() as w:
logger.info('Removing document with id=%s from the search index.', pw.rel_path)
w.delete_by_term("id", pw.rel_path)
logger.info('Removing document with id=%s from the search index.', pp.rel_path)
w.delete_by_term("id", pp.rel_path)
def update_item(pw: page_wrapped):
def update_item(pp: PikiPage):
ix = load_index()
delete_item(ix, pw)
add_item(ix, pw)
delete_item(ix, pp)
add_item(ix, pp)

View File

@ -6,7 +6,8 @@ from django.utils.translation import gettext as _
import logging
from . import access
from .access import access_control
from . import messages
from . import url_page
from . import get_search_query
@ -14,14 +15,25 @@ import config
from .context import context_adaption
from .forms import EditForm, RenameForm
from .help import help_pages
from .models import PikiPage, page_list
import mycreole
from .page import page_wrapped, page_list
from .search import whoosh_search, load_index, delete_item, add_item
from .search import whoosh_search, load_index, delete_item, add_item, update_item
from themes import Context
logger = logging.getLogger(settings.ROOT_LOGGER_NAME).getChild(__name__)
SUCCESS_PAGE = _("""= Default startpage
**Congratulations!!!**
Seeing this page means, that you installed Piki successfull.
Edit this page to get your own first startpage.
If you need need assistance to edit a page, visit the [[/helpview/main|help pages]].
""")
def root(request):
return HttpResponseRedirect(url_page(config.STARTPAGE))
@ -29,19 +41,36 @@ def root(request):
def page(request, rel_path):
context = Context(request) # needs to be executed first because of time mesurement
#
try:
p = PikiPage.objects.get(rel_path=rel_path)
except PikiPage.DoesNotExist:
p = None
meta = "meta" in request.GET
history = request.GET.get("history")
if history:
history = int(history)
#
p = page_wrapped(request, rel_path, history_version=history)
if access.read_page(request, rel_path):
if meta:
page_content = p.render_meta()
title = rel_path.split("/")[-1]
#
acc = access_control(request, rel_path)
if acc.may_read():
if p is None or p.deleted:
if rel_path == config.STARTPAGE:
page_content = mycreole.render_simple(SUCCESS_PAGE)
else:
page_content = ""
if p is not None and p.deleted:
messages.deleted_page(request)
else:
messages.unavailable_msg_page(request, rel_path)
else:
page_content = p.render_to_html()
if history:
messages.history_version_display(request, rel_path, history)
title = p.title
if meta:
page_content = p.render_meta(request, history)
else:
page_content = p.render_to_html(request, history)
if history:
messages.history_version_display(request, rel_path, history)
else:
messages.permission_denied_msg_page(request, rel_path)
page_content = ""
@ -50,65 +79,75 @@ def page(request, rel_path):
context,
request,
rel_path=rel_path,
title=p.title,
upload_path=p.attachment_path,
title=title,
upload_path=rel_path,
page_content=page_content,
is_available=p.userpage_is_available()
is_available=p is not None and not p.deleted
)
return render(request, 'pages/page.html', context=context)
def edit(request, rel_path):
if access.write_page(request, rel_path):
acc = access_control(request, rel_path)
if acc.may_write():
context = Context(request) # needs to be executed first because of time mesurement
#
try:
p = PikiPage.objects.get(rel_path=rel_path)
is_available = True
except PikiPage.DoesNotExist:
p = PikiPage(rel_path=rel_path)
is_available = False
#
if not request.POST:
history = request.GET.get("history")
if history:
history = int(history)
#
p = page_wrapped(request, rel_path, history_version=history)
#
form = EditForm(page_data=p.raw_page_src, page_tags=p.tags)
form = EditForm(instance=p.history.get(history_id=history))
else:
form = EditForm(instance=p)
#
context_adaption(
context,
request,
rel_path=rel_path,
is_available=p.userpage_is_available(),
is_available=is_available,
form=form,
# TODO: Add translation
title=_("Edit page %s") % repr(p.title),
upload_path=p.attachment_path,
upload_path=rel_path,
)
return render(request, 'pages/page_edit.html', context=context)
else:
p = page_wrapped(request, rel_path)
form = EditForm(request.POST, instance=p)
#
save = request.POST.get("save")
page_txt = request.POST.get("page_txt")
tags = request.POST.get("page_tags")
preview = request.POST.get("preview")
#
if save is not None:
if p.update_page(page_txt, tags):
messages.edit_success(request)
if form.is_valid():
form.instance.prepare_save(request)
page = form.save()
if page.save_needed:
messages.edit_success(request)
# update search index
update_item(page)
else:
messages.no_change(request)
else:
messages.no_change(request)
messages.internal_error(request)
return HttpResponseRedirect(url_page(rel_path))
elif preview is not None:
form = EditForm(page_data=page_txt, page_tags=tags)
#
context_adaption(
context,
request,
rel_path=rel_path,
is_available=p.userpage_is_available(),
is_available=is_available,
form=form,
# TODO: Add translation
title=_("Edit page %s") % repr(p.title),
upload_path=p.attachment_path,
page_content=p.render_text(request, page_txt)
upload_path=rel_path,
page_content=p.render_text(request, form.data.get("page_txt"))
)
return render(request, 'pages/page_edit.html', context=context)
else:
@ -119,11 +158,18 @@ def edit(request, rel_path):
def delete(request, rel_path):
if access.write_page(request, rel_path):
acc = access_control(request, rel_path)
if acc.may_write():
context = Context(request) # needs to be executed first because of time mesurement
#
try:
p = PikiPage.objects.get(rel_path=rel_path)
is_available = True
except PikiPage.DoesNotExist:
p = PikiPage(rel_path=rel_path)
is_available = False
#
if not request.POST:
p = page_wrapped(request, rel_path)
#
# form = DeleteForm(page_data=p.raw_page_src, page_tags=p.tags)
#
@ -131,24 +177,21 @@ def delete(request, rel_path):
context,
request,
rel_path=rel_path,
is_available=p.userpage_is_available(),
# form=form,
is_available=is_available,
# TODO: Add translation
title=_("Delete page %s") % repr(p.title),
upload_path=p.attachment_path,
page_content=p.render_to_html(),
upload_path=rel_path,
page_content=p.render_to_html(request),
)
else:
p = page_wrapped(request, rel_path)
#
delete = request.POST.get("delete")
#
if delete:
p.deleted = True
p.save()
# delete page from search index
ix = load_index()
delete_item(ix, p)
# delete move files to history
p.delete()
# add delete message
messages.page_deleted(request, p.title)
return HttpResponseRedirect("/")
@ -162,28 +205,32 @@ def delete(request, rel_path):
def rename(request, rel_path):
if access.write_page(request, rel_path):
acc = access_control(request, rel_path)
if acc.may_write():
context = Context(request) # needs to be executed first because of time mesurement
#
try:
p = PikiPage.objects.get(rel_path=rel_path)
is_available = True
except PikiPage.DoesNotExist:
p = PikiPage(rel_path=rel_path)
is_available = False
#
if not request.POST:
p = page_wrapped(request, rel_path)
#
form = RenameForm(page_name=p.rel_path)
#
context_adaption(
context,
request,
rel_path=rel_path,
is_available=p.userpage_is_available(),
is_available=is_available,
form=form,
# TODO: Add translation
title=_("Delete page %s") % repr(p.title),
upload_path=p.attachment_path,
page_content=p.render_to_html(),
upload_path=rel_path,
page_content=p.render_to_html(request),
)
else:
p = page_wrapped(request, rel_path)
#
rename = request.POST.get("rename")
page_name = request.POST.get("page_name")
if rename:
@ -194,7 +241,8 @@ def rename(request, rel_path):
ix = load_index()
delete_item(ix, p)
# rename the storage folder
p.rename(page_name)
p.rel_path = page_name
p.save()
# add the renamed page to the search index
add_item(ix, p)
# add rename message
@ -217,7 +265,7 @@ def search(request):
if sr is None:
django_messages.error(request, _('Invalid search pattern: %s') % repr(search_txt))
sr = []
pl = page_list(request, [page_wrapped(request, rel_path) for rel_path in set(sr)])
pl = page_list([PikiPage.objects.get(rel_path=rel_path) for rel_path in set(sr)])
#
context_adaption(
context,

View File

@ -41,6 +41,8 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
#
'simple_history',
]
MIDDLEWARE = [
@ -124,7 +126,7 @@ STATIC_URL = 'static/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'data', 'media')
MEDIA_URL = '/media/'
MYCREOLE_ROOT = os.path.join(BASE_DIR, 'data', 'pages')
MYCREOLE_ROOT = os.path.join(BASE_DIR, 'data', 'mycreole')
MYCREOLE_ATTACHMENT_ACCESS = {
'read': 'pages.access.read_attachment',
'modify': 'pages.access.modify_attachment',