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.
This commit is contained in:
davewiththenicehat 2021-06-04 16:24:51 -04:00
parent eef840efbf
commit 2fe14ebcbc
5 changed files with 310 additions and 37 deletions

View file

@ -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<slug>[\w\d\-]+)/(?P<pk>[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.

View file

@ -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<slug>[\w\d\-]+)/(?P<pk>[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

View file

@ -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 "#"

View file

@ -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

View file

@ -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", "<br />")
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