From 2fe14ebcbc57724f9564ca4164776aebb44e1966 Mon Sep 17 00:00:00 2001 From: davewiththenicehat <54369722+davewiththenicehat@users.noreply.github.com> Date: Fri, 4 Jun 2021 16:24:51 -0400 Subject: [PATCH] file and command help web support, proof of concept Not fleshed enough for a prototype. Is a working proof of concept. Adds command help and file help entries to the website. --- evennia/commands/command.py | 39 +++++ evennia/help/filehelp.py | 42 +++++ evennia/help/models.py | 6 +- evennia/web/website/tests.py | 11 ++ evennia/web/website/views/help.py | 249 +++++++++++++++++++++++++----- 5 files changed, 310 insertions(+), 37 deletions(-) diff --git a/evennia/commands/command.py b/evennia/commands/command.py index b4519fc8a2..4cc764cc6f 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -9,11 +9,14 @@ import math import inspect from django.conf import settings +from django.urls import reverse +from django.utils.text import slugify from evennia.locks.lockhandler import LockHandler from evennia.utils.utils import is_iter, fill, lazy_property, make_iter from evennia.utils.evtable import EvTable from evennia.utils.ansi import ANSIString +from evennia.utils.logger import log_info class InterruptCommand(Exception): @@ -514,6 +517,42 @@ Command {self} has no defined `func()` - showing on-command variables: """ return self.__doc__ + def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + ex. Oscar (Character) = '/characters/oscar/1/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + ex. + :: + url(r'characters/(?P[\w\d\-]+)/(?P[0-9]+)/$', + CharDetailView.as_view(), name='character-detail') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + Returns: + path (str): URI path to object detail page, if defined. + + """ + try: + return reverse( + 'help-entry-detail', + kwargs={"category": slugify(self.help_category), "topic": slugify(self.key)}, + ) + except Exception as e: + log_info(f'Exception: {getattr(e, "message", repr(e))}') + return "#" + def client_width(self): """ Get the client screenwidth for the session using this command. diff --git a/evennia/help/filehelp.py b/evennia/help/filehelp.py index 772d804fcc..c5752ca1e9 100644 --- a/evennia/help/filehelp.py +++ b/evennia/help/filehelp.py @@ -67,11 +67,14 @@ An example of the contents of a module: from dataclasses import dataclass from django.conf import settings +from django.urls import reverse +from django.utils.text import slugify from evennia.utils.utils import ( variable_from_module, make_iter, all_from_module) from evennia.utils import logger from evennia.utils.utils import lazy_property from evennia.locks.lockhandler import LockHandler +from evennia.utils.logger import log_info _DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY @@ -115,6 +118,43 @@ class FileHelpEntry: def locks(self): return LockHandler(self) + def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + ex. Oscar (Character) = '/characters/oscar/1/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + ex. + :: + url(r'characters/(?P[\w\d\-]+)/(?P[0-9]+)/$', + CharDetailView.as_view(), name='character-detail') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + Returns: + path (str): URI path to object detail page, if defined. + + """ + # log_info('filehelp web_get_detail_url start') + try: + return reverse( + 'help-entry-detail', + kwargs={"category": slugify(self.help_category), "topic": slugify(self.key)}, + ) + except Exception as e: + log_info(f'Exception: {getattr(e, "message", repr(e))}') + return "#" + def access(self, accessing_obj, access_type="view", default=True): """ Determines if another object has permission to access this help entry. @@ -207,3 +247,5 @@ class FileHelpStorageHandler: # singleton to hold the loaded help entries FILE_HELP_ENTRIES = FileHelpStorageHandler() +# Used by Django Sites/Admin +#get_absolute_url = web_get_detail_url diff --git a/evennia/help/models.py b/evennia/help/models.py index 2671687b31..f7285aed75 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -19,6 +19,7 @@ from evennia.help.manager import HelpEntryManager from evennia.typeclasses.models import Tag, TagHandler, AliasHandler from evennia.locks.lockhandler import LockHandler from evennia.utils.utils import lazy_property +from evennia.utils.logger import log_info __all__ = ("HelpEntry",) @@ -221,11 +222,14 @@ class HelpEntry(SharedMemoryModel): path (str): URI path to object detail page, if defined. """ + try: - return reverse( + url = reverse( "%s-detail" % slugify(self._meta.verbose_name), kwargs={"category": slugify(self.db_help_category), "topic": slugify(self.db_key)}, ) + # log_info(f'HelpEntry web_get_detail_url url: {url}') + return url except Exception: return "#" diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index a4633f51f4..7f0b41df9b 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -135,6 +135,17 @@ class ChannelDetailTest(EvenniaWebTest): return {"slug": slugify("demo")} +class HelpListTest(EvenniaWebTest): + url_name = "help" + + +class HelpDetailTest(EvenniaWebTest): + url_name = "help-entry-detail" + + def get_kwargs(self): + return {"category": slugify("general"), + "topic": slugify("test-key")} + class CharacterCreateView(EvenniaWebTest): url_name = "character-create" unauthenticated_response = 302 diff --git a/evennia/web/website/views/help.py b/evennia/web/website/views/help.py index afa439064d..3f1e6f4608 100644 --- a/evennia/web/website/views/help.py +++ b/evennia/web/website/views/help.py @@ -2,14 +2,152 @@ Views to manipulate help entries. """ - +from dataclasses import dataclass from django.utils.text import slugify +from django.conf import settings +from evennia.utils.utils import inherits_from from django.views.generic import ListView from django.http import HttpResponseBadRequest from django.db.models.functions import Lower from evennia.help.models import HelpEntry +from evennia.help.filehelp import FILE_HELP_ENTRIES from .mixins import TypeclassMixin, EvenniaDetailView +from django.views.generic import DetailView +from evennia.utils.logger import log_info +DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY + +def get_help_category(help_entry): + if hasattr(help_entry, 'help_category'): + return help_entry.help_category + elif hasattr(help_entry, 'category'): + return help_entry.category + elif hasattr(help_entry, 'db_help_category'): + return help_entry.db_help_category + else: + return 'unsorted' + +def get_help_topic(help_entry): + topic = getattr(help_entry, 'key', False) + if not topic: + getattr(help_entry, 'db_key', False) + # log_info(f'get_help_topic returning: {topic}') + return topic + +def can_read_topic(cmd_or_topic, caller): + """ + Helper method. If this return True, the given help topic + be viewable in the help listing. Note that even if this returns False, + the entry will still be visible in the help index unless `should_list_topic` + is also returning False. + Args: + cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test. + caller: the caller checking for access. + Returns: + bool: If command can be viewed or not. + Notes: + This uses the 'read' lock. If no 'read' lock is defined, the topic is assumed readable + by all. + """ + if inherits_from(cmd_or_topic, "evennia.commands.command.Command"): + return cmd_or_topic.auto_help and cmd_or_topic.access(caller, 'read', default=True) + else: + return cmd_or_topic.access(caller, 'read', default=True) + +def can_list_topic(cmd_or_topic, caller): + """ + Should the specified command appear in the help table? + This method only checks whether a specified command should appear in the table of + topics/commands. The command can be used by the caller (see the 'should_show_help' method) + and the command will still be available, for instance, if a character type 'help name of the + command'. However, if you return False, the specified command will not appear in the table. + This is sometimes useful to "hide" commands in the table, but still access them through the + help system. + Args: + cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test. + caller: the caller checking for access. + Returns: + bool: If command should be listed or not. + Notes: + By default, the 'view' lock will be checked, and if no such lock is defined, the 'read' + lock will be used. If neither lock is defined, the help entry is assumed to be + accessible to all. + """ + has_view = ( + "view:" in cmd_or_topic.locks + if inherits_from(cmd_or_topic, "evennia.commands.command.Command") + else cmd_or_topic.locks.get("view") + ) + + if has_view: + return cmd_or_topic.access(caller, 'view', default=True) + else: + # no explicit 'view' lock - use the 'read' lock + return cmd_or_topic.access(caller, 'read', default=True) + +def collect_topics(caller, mode='list'): + """ + Collect help topics from all sources (cmd/db/file). + Args: + caller (Object or Account): The user of the Command. + mode (str): One of 'list' or 'query', where the first means we are collecting to view + the help index and the second because of wanting to search for a specific help + entry/cmd to read. This determines which access should be checked. + Returns: + tuple: A tuple of three dicts containing the different types of help entries + in the order cmd-help, db-help, file-help: + `({key: cmd,...}, {key: dbentry,...}, {key: fileentry,...}` + """ + # start with cmd-help + + # get Character's primary command set. + cmdset = caller.cmdset.get()[0] + + # removing doublets in cmdset, caused by cmdhandler + # having to allow doublet commands to manage exits etc. + cmdset.make_unique(caller) + + # retrieve all available commands and database / file-help topics. + # also check the 'cmd:' lock here + cmd_help_topics = [cmd for cmd in cmdset if cmd and cmd.access(caller, 'cmd')] + # get all file-based help entries, checking perms + file_help_topics = { + topic.key.lower().strip(): topic + for topic in FILE_HELP_ENTRIES.all() + } + # get db-based help entries, checking perms + db_help_topics = { + topic.key.lower().strip(): topic + for topic in HelpEntry.objects.all() + } + if mode == 'list': + # check the view lock for all help entries/commands and determine key + cmd_help_topics = { + cmd.auto_help_display_key + if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd + for cmd in cmd_help_topics if can_list_topic(cmd, caller)} + db_help_topics = { + key: entry for key, entry in db_help_topics.items() + if can_list_topic(entry, caller) + } + file_help_topics = { + key: entry for key, entry in file_help_topics.items() + if can_list_topic(entry, caller)} + else: + # query + cmd_help_topics = { + cmd.auto_help_display_key + if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd + for cmd in cmd_help_topics if can_read_topic(cmd, caller)} + db_help_topics = { + key: entry for key, entry in db_help_topics.items() + if can_read_topic(entry, caller) + } + file_help_topics = { + key: entry for key, entry in file_help_topics.items() + if can_read_topic(entry, caller)} + + return cmd_help_topics, db_help_topics, file_help_topics class HelpMixin(TypeclassMixin): """ @@ -35,22 +173,36 @@ class HelpMixin(TypeclassMixin): queryset (QuerySet): List of Help entries available to the user. """ + log_info('get_queryset') account = self.request.user + all_entries = [] + if not str(account) == 'AnonymousUser': + # collect all help entries + cmd_help_topics, db_help_topics, file_help_topics = \ + collect_topics(account.db._playable_characters[0], mode='query') + # combine and sort all the help entries + file_db_help_topics = {**file_help_topics, **db_help_topics} + all_topics = {**file_db_help_topics, **cmd_help_topics} + all_entries = list(all_topics.values()) + all_entries.sort(key=get_help_category) + # log_info(f'{all_entries}') + log_info('get_queryset success') + return all_entries - # Get list of all HelpEntries - entries = self.typeclass.objects.all().iterator() - - # Now figure out which ones the current user is allowed to see - bucket = [entry.id for entry in entries if entry.access(account, "view")] - - # Re-query and set a sorted list - filtered = ( - self.typeclass.objects.filter(id__in=bucket) - .order_by(Lower("db_key")) - .order_by(Lower("db_help_category")) - ) - - return filtered + def get_entries(self): + account = self.request.user + all_entries = [] + if not str(account) == 'AnonymousUser': + # collect all help entries + cmd_help_topics, db_help_topics, file_help_topics = \ + collect_topics(account.db._playable_characters[0], mode='query') + # combine and sort all the help entries + file_db_help_topics = {**file_help_topics, **db_help_topics} + all_topics = {**file_db_help_topics, **cmd_help_topics} + all_entries = list(all_topics.values()) + all_entries.sort(key=get_help_category) + # log_info(f'{all_entries}') + return all_entries class HelpListView(HelpMixin, ListView): @@ -68,7 +220,7 @@ class HelpListView(HelpMixin, ListView): page_title = "Help Index" -class HelpDetailView(HelpMixin, EvenniaDetailView): +class HelpDetailView(HelpMixin, DetailView): """ Returns the detail page for a given help entry. @@ -77,6 +229,14 @@ class HelpDetailView(HelpMixin, EvenniaDetailView): # -- Django constructs -- template_name = "website/help_detail.html" + @property + def page_title(self): + # Makes sure the page has a sensible title. + #return "%s Detail" % self.typeclass._meta.verbose_name.title() + obj = self.get_object() + topic = get_help_topic(obj) + return f'{topic} detail' + def get_context_data(self, **kwargs): """ Adds navigational data to the template to let browsers go to the next @@ -86,21 +246,26 @@ class HelpDetailView(HelpMixin, EvenniaDetailView): context (dict): Django context object """ + log_info('get_context_data') context = super().get_context_data(**kwargs) # Get the object in question obj = self.get_object() # Get queryset and filter out non-related categories - queryset = ( - self.get_queryset() - .filter(db_help_category=obj.db_help_category) - .order_by(Lower("db_key")) - ) - context["topic_list"] = queryset + full_set = self.get_queryset() + obj_topic = get_help_category(obj) + topic_set = [] + for entry in full_set: + entry_topic = get_help_category(entry) + if entry_topic.lower() == obj_topic.lower(): + topic_set.append(entry) + context["topic_list"] = topic_set + + # log_info(f'topic_set: {topic_set}') # Find the index position of the given obj in the queryset - objs = list(queryset) + objs = list(topic_set) for i, x in enumerate(objs): if obj is x: break @@ -119,12 +284,18 @@ class HelpDetailView(HelpMixin, EvenniaDetailView): context["topic_previous"] = None # Format the help entry using HTML instead of newlines - text = obj.db_entrytext + text = 'Failed to find entry.' + if inherits_from(obj, "evennia.commands.command.Command"): + text = obj.__doc__ + elif inherits_from(obj, "evennia.help.models.HelpEntry"): + text = obj.db_entrytext + elif inherits_from(obj, "evennia.help.filehelp.FileHelpEntry"): + text = obj.entrytext text = text.replace("\r\n\r\n", "\n\n") text = text.replace("\r\n", "\n") text = text.replace("\n", "
") context["entry_text"] = text - + log_info('get_context_data success') return context def get_object(self, queryset=None): @@ -136,27 +307,33 @@ class HelpDetailView(HelpMixin, EvenniaDetailView): entry (HelpEntry): HelpEntry requested in the URL. """ + log_info('get_object start') # Get the queryset for the help entries the user can access if not queryset: queryset = self.get_queryset() - # Find the object in the queryset + # get the category and topic requested category = slugify(self.kwargs.get("category", "")) topic = slugify(self.kwargs.get("topic", "")) - obj = next( - ( - x - for x in queryset - if slugify(x.db_help_category) == category and slugify(x.db_key) == topic - ), - None, - ) + + # Find the object in the queryset + obj = None + for entry in queryset: + # continue to next entry if the topics do not match + entry_topic = get_help_topic(entry) + if not entry_topic.lower() == topic.replace('-', ' '): + continue + # if the category also matches, object requested is found + entry_category = get_help_category(entry) + if entry_category.lower() == category.replace('-', ' '): + obj = entry + break # Check if this object was requested in a valid manner if not obj: return HttpResponseBadRequest( - "No %(verbose_name)s found matching the query" - % {"verbose_name": queryset.model._meta.verbose_name} + f"No ({category}/{topic})s found matching the query" ) + log_info(f'get_obj returning {obj}') return obj