diff --git a/.gitignore b/.gitignore index ff313d8..55f0a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # piki data/media -data/pages +data/mycreole data/static data/whoosh db.sqlite3 diff --git a/data/system-pages/index/meta.json b/data/system-pages/index/meta.json deleted file mode 100644 index 03bcbfb..0000000 --- a/data/system-pages/index/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "creation_time": 1728465254, - "modified_time": 1728465254, - "modified_user": "system-page" -} diff --git a/data/system-pages/index/page b/data/system-pages/index/page deleted file mode 100644 index 52db808..0000000 --- a/data/system-pages/index/page +++ /dev/null @@ -1,2 +0,0 @@ -= Index -<> \ No newline at end of file diff --git a/data/system-pages/startpage/meta.json b/data/system-pages/startpage/meta.json deleted file mode 100644 index b5794c1..0000000 --- a/data/system-pages/startpage/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "creation_time": 1728465495, - "modified_time": 1728649989, - "modified_user": "system-page" -} diff --git a/data/system-pages/startpage/page b/data/system-pages/startpage/page deleted file mode 100644 index 1d9b218..0000000 --- a/data/system-pages/startpage/page +++ /dev/null @@ -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]]. - diff --git a/data/system-pages/tree/meta.json b/data/system-pages/tree/meta.json deleted file mode 100644 index 4f1c725..0000000 --- a/data/system-pages/tree/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "modified_time": 1729022206, - "modified_user": "system-page", - "creation_time": 1729022206 -} diff --git a/data/system-pages/tree/page b/data/system-pages/tree/page deleted file mode 100644 index e721642..0000000 --- a/data/system-pages/tree/page +++ /dev/null @@ -1,2 +0,0 @@ -= Tree -<> diff --git a/pages/access.py b/pages/access.py index 083fd77..6917f7f 100644 --- a/pages/access.py +++ b/pages/access.py @@ -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() diff --git a/pages/admin.py b/pages/admin.py index 8c38f3f..6260b56 100644 --- a/pages/admin.py +++ b/pages/admin.py @@ -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) diff --git a/pages/context.py b/pages/context.py index 3855e9e..a123bc7 100644 --- a/pages/context.py +++ b/pages/context.py @@ -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) diff --git a/pages/forms.py b/pages/forms.py index 44233b1..256975b 100644 --- a/pages/forms.py +++ b/pages/forms.py @@ -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 diff --git a/pages/management/commands/import_system_pages.py b/pages/management/commands/import_system_pages.py new file mode 100644 index 0000000..42f96eb --- /dev/null +++ b/pages/management/commands/import_system_pages.py @@ -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 +<>""", + "index": """= Index +<>""", +} + + +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 + ) diff --git a/pages/management/commands/migrate_to_db.py b/pages/management/commands/migrate_to_db.py new file mode 100644 index 0000000..2399c51 --- /dev/null +++ b/pages/management/commands/migrate_to_db.py @@ -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) diff --git a/pages/messages.py b/pages/messages.py index 2a31ded..98c0c74 100644 --- a/pages/messages.py +++ b/pages/messages.py @@ -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 here to recover this Version.") % ( diff --git a/pages/migrations/0001_initial.py b/pages/migrations/0001_initial.py new file mode 100644 index 0000000..69427fc --- /dev/null +++ b/pages/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/pages/models.py b/pages/models.py index 71a8362..80dc4fc 100644 --- a/pages/models.py +++ b/pages/models.py @@ -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 "
\n" + page_tree(pl).html() + "
\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'{entry}' + 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 diff --git a/pages/page.py b/pages/page.py index bb6bff7..474367e 100644 --- a/pages/page.py +++ b/pages/page.py @@ -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 "
\n" + page_tree(pl).html() + "
\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'{entry}' - 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): diff --git a/pages/search.py b/pages/search.py index 80bcce4..7964f7f 100644 --- a/pages/search.py +++ b/pages/search.py @@ -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) diff --git a/pages/views.py b/pages/views.py index 9631542..9a461aa 100644 --- a/pages/views.py +++ b/pages/views.py @@ -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, diff --git a/piki/settings.py b/piki/settings.py index 0203888..2f2c39c 100644 --- a/piki/settings.py +++ b/piki/settings.py @@ -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',