Merge branch 'cmd_and_file_help_web_support' of https://github.com/davewiththenicehat/evennia into davewiththenicehat-cmd_and_file_help_web_support

This commit is contained in:
Griatch 2021-07-26 21:41:00 +02:00
commit d00915d092
7 changed files with 437 additions and 82 deletions

View file

@ -9,6 +9,8 @@ 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
@ -514,6 +516,53 @@ 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:
return "#"
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
return False
def client_width(self):
"""
Get the client screenwidth for the session using this command.

View file

@ -67,6 +67,8 @@ 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
@ -115,6 +117,53 @@ 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.
"""
try:
return reverse(
'help-entry-detail',
kwargs={"category": slugify(self.help_category), "topic": slugify(self.key)},
)
except Exception:
return "#"
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
return False
def access(self, accessing_obj, access_type="view", default=True):
"""
Determines if another object has permission to access this help entry.

View file

@ -56,6 +56,7 @@ class HelpEntry(SharedMemoryModel):
db_key = models.CharField(
"help key", max_length=255, unique=True, help_text="key to search for"
)
# help category
db_help_category = models.CharField(
"help category",
@ -63,6 +64,7 @@ class HelpEntry(SharedMemoryModel):
default="General",
help_text="organizes help entries in lists",
)
# the actual help entry text, in any formatting.
db_entrytext = models.TextField(
"help entry", blank=True, help_text="the main body of help text"
@ -221,6 +223,7 @@ class HelpEntry(SharedMemoryModel):
path (str): URI path to object detail page, if defined.
"""
try:
return reverse(
"%s-detail" % slugify(self._meta.verbose_name),

View file

@ -9,24 +9,24 @@
{% load addclass %}
<div class="row">
<div class="col">
<!-- main content -->
<!-- main content -->
<div class="card mb-3">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }} ({{ object|title }})</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'help' %}">Help Index</a></li>
<li class="breadcrumb-item"><a href="{% url 'help' %}#{{ object.db_help_category }}">{{ object.db_help_category|title }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ object.db_key|title }}</li>
<li class="breadcrumb-item"><a href="{% url 'help' %}#{{ object.web_help_category }}">{{ object.help_category|title }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ object.web_help_key|title }}</li>
</ol>
<hr />
<div class="row">
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<p>{{ entry_text }}</p>
<pre>{{ entry_text }}</pre>
{% if topic_previous or topic_next %}
<hr />
<!-- navigation -->
@ -37,7 +37,7 @@
<a class="page-link" href="{{ topic_previous.web_get_detail_url }}">Previous ({{ topic_previous|title }})</a>
</li>
{% endif %}
{% if topic_next %}
<li class="page-item">
<a class="page-link" href="{{ topic_next.web_get_detail_url }}">Next ({{ topic_next|title }})</a>
@ -47,40 +47,40 @@
</nav>
<!-- end navigation -->
{% endif %}
</div>
<!-- end left column -->
<!-- right column (sidebar) -->
<div class="col-lg-3 col-sm-12">
{% if request.user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
<div class="card mb-3">
<div class="card-header">{{ object.db_help_category|title }}</div>
<div class="card-header">{{ object.web_help_category|title }}</div>
<ul class="list-group list-group-flush">
{% for topic in topic_list %}
<a href="{{ topic.web_get_detail_url }}" class="list-group-item {% if topic == object %}active disabled{% endif %}">{{ topic|title }}</a>
{% endfor %}
</ul>
</div>
</div>
<!-- end right column -->
</div>
<hr />
</div>
</div>
<!-- end main content -->
</div>
</div>
{% endblock %}

View file

@ -18,12 +18,12 @@
</ol>
<hr />
<div class="row">
{% regroup object_list by help_category as category_list %}
{% regroup object_list by web_help_category as category_list %}
{% if category_list %}
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<!-- intro -->
<div class="card border-light">
<div class="card-body">
@ -33,23 +33,30 @@
</div>
<hr />
<!-- end intro -->
<!-- index list -->
<div class="mx-3">
{% for help_category in category_list %}
<h5><a id="{{ help_category.grouper }}"></a>{{ help_category.grouper|title }}</h5>
{% for web_help_category in category_list %}
<h5><a id="{{ web_help_category.grouper }}"></a>{{ web_help_category.grouper|title }}</h5>
<ul>
{% for object in help_category.list %}
<li><a href="{{ object.web_get_detail_url }}">{{ object|title }}</a></li>
{% for object in web_help_category.list %}
<li>
<a href="{{ object.web_get_detail_url }}">{{ object|title }}</a>
{% if object.web_get_admin_url %}
{% if user.is_staff %}
<a href="{{ object.web_get_admin_url }}">-edit-</a>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
<!-- end index list -->
</div>
</div>
<!-- end left column -->
<!-- right column (index) -->
<div class="col-lg-3 col-sm-12">
{% if user.is_staff %}
@ -58,16 +65,16 @@
<!-- end admin button -->
<hr />
{% endif %}
<div class="card mb-3">
<div class="card-header">Category Index</div>
<ul class="list-group list-group-flush">
{% for category in category_list %}
<a href="#{{ category.grouper }}" class="list-group-item">{{ category.grouper|title }}</a>
{% endfor %}
</ul>
</div>
</div>
<!-- end right column -->
@ -81,14 +88,14 @@
<p>You're missing out on an opportunity to attract visitors (and potentially new players) to {{ game_name }}!</p>
<p>Use Evennia's <a href="https://github.com/evennia/evennia/wiki/Help-System#database-help-entries" class="alert-link" target="_blank">Help System</a> to tell the world about the universe you've created, its lore and legends, its people and creatures, and their customs and conflicts!</p>
<p>You don't even need coding skills-- writing Help Entries is no more complicated than writing an email or blog post. Once you publish your first entry, these ugly boxes go away and this page will turn into an index of everything you've written about {{ game_name }}.</p>
<p>The documentation you write is eventually picked up by search engines, so the more you write about how {{ game_name }} works, the larger your web presence will be-- and the more traffic you'll attract.
<p>The documentation you write is eventually picked up by search engines, so the more you write about how {{ game_name }} works, the larger your web presence will be-- and the more traffic you'll attract.
<p>Everything you write can be viewed either on this site or within the game itself, using the in-game help commands.</p>
<hr>
<p class="mb-0"><a href="/admin/help/helpentry/add/" class="alert-link">Click here</a> to start writing about {{ game_name }}!</p>
</div>
</div>
{% endif %}
<div class="col-lg-12 col-sm-12">
<div class="alert alert-secondary" role="alert">
<h4 class="alert-heading">Under Construction!</h4>
@ -99,7 +106,7 @@
</div>
</div>
{% endif %}
</div>
<hr />
</div>

View file

@ -3,7 +3,11 @@ from django.utils.text import slugify
from django.test import Client, override_settings
from django.urls import reverse
from evennia.utils import class_from_module
from evennia.utils.create import create_help_entry
from evennia.utils.test_resources import EvenniaTest
from evennia.help import filehelp
_FILE_HELP_ENTRIES = None
class EvenniaWebTest(EvenniaTest):
@ -135,6 +139,79 @@ class ChannelDetailTest(EvenniaWebTest):
return {"slug": slugify("demo")}
class HelpListTest(EvenniaWebTest):
url_name = "help"
HELP_ENTRY_DICTS = [
{
"key": "unit test file entry",
"category": "General",
"text": "cache test file entry text"
}
]
class HelpDetailTest(EvenniaWebTest):
url_name = "help-entry-detail"
def setUp(self):
super().setUp()
# create a db help entry
create_help_entry('unit test db entry', 'unit test db entry text', category="General")
def get_kwargs(self):
return {"category": slugify("general"),
"topic": slugify('unit test db entry')}
def test_view(self):
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.context["entry_text"], 'unit test db entry text')
def test_object_cache(self):
# clear file help entries, use local HELP_ENTRY_DICTS to recreate new entries
global _FILE_HELP_ENTRIES
if _FILE_HELP_ENTRIES is None:
from evennia.help.filehelp import FILE_HELP_ENTRIES as _FILE_HELP_ENTRIES
help_module = 'evennia.web.website.tests'
self.file_help_store = _FILE_HELP_ENTRIES.__init__(help_file_modules=[help_module])
# request access to an entry
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.context["entry_text"], 'unit test db entry text')
# request a second entry, verifing the cached object is not provided on a new topic request
entry_two_args = {"category": slugify("general"), "topic": slugify('unit test file entry')}
response = self.client.get(reverse(self.url_name, kwargs=entry_two_args), follow=True)
self.assertEqual(response.context["entry_text"], 'cache test file entry text')
class HelpLockedDetailTest(EvenniaWebTest):
url_name = "help-entry-detail"
def setUp(self):
super(HelpLockedDetailTest, self).setUp()
# create a db entry with a lock
self.db_help_entry = create_help_entry('unit test locked topic', 'unit test locked entrytext',
category="General", locks='read:perm(Developer)')
def get_kwargs(self):
return {"category": slugify("general"),
"topic": slugify('unit test locked topic')}
def test_locked_entry(self):
# request access to an entry for permission the account does not have
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.context["entry_text"], 'Failed to find entry.')
def test_lock_with_perm(self):
# log TestAccount in, grant permission required, read the entry
self.login()
self.account.permissions.add("Developer")
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.context["entry_text"], 'unit test locked entrytext')
class CharacterCreateView(EvenniaWebTest):
url_name = "character-create"
unauthenticated_response = 302

View file

@ -1,17 +1,161 @@
"""
Views to manipulate help entries.
Multi entry object type supported added by DaveWithTheNiceHat 2021
Pull Request #2429
"""
from django.utils.text import slugify
from django.views.generic import ListView
from django.conf import settings
from evennia.utils.utils import inherits_from
from django.views.generic import ListView, DetailView
from django.http import HttpResponseBadRequest
from django.db.models.functions import Lower
from evennia.help.models import HelpEntry
from .mixins import TypeclassMixin, EvenniaDetailView
from evennia.help.filehelp import FILE_HELP_ENTRIES
from evennia.utils.ansi import strip_ansi
DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY
class HelpMixin(TypeclassMixin):
def get_help_category(help_entry, slugify_cat=True):
"""Returns help category.
Args:
help_entry (HelpEntry, FileHelpEntry or Command): Help entry instance.
slugify_cat (bool): If true the return string is slugified. Default is True.
Notes:
If an entry does not have a `help_category` attribute, DEFAULT_HELP_CATEGORY from the
settings file is used.
If the entry does not have attribute 'web_help_entries'. One is created with a slugified
copy of the attribute help_category. This attribute is used for sorting the entries for the
help index (ListView) page.
Returns:
help_category (str): The category for the help entry.
"""
help_category = getattr(help_entry, 'help_category', None)
if not help_category:
help_category = getattr(help_entry, 'db_help_category', DEFAULT_HELP_CATEGORY)
# if one does not exist, create a category for ease of use with web views html templates
if not hasattr(help_entry, 'web_help_category'):
setattr(help_entry, 'web_help_category', slugify(help_category))
return slugify(help_category) if slugify_cat else help_category
def get_help_topic(help_entry):
"""Get the topic of the help entry.
Args:
help_entry (HelpEntry, FileHelpEntry or Command): Help entry instance.
Returns:
help_topic (str): The topic of the help entry. Default is 'unknown_topic'.
"""
help_topic = getattr(help_entry, 'key', None)
# if object has no key, assume it is a db help entry.
if not help_topic:
help_topic = getattr(help_entry, 'db_key', 'unknown_topic')
# if one does not exist, create a key for ease of use with web views html templates
if not hasattr(help_entry, 'web_help_key'):
setattr(help_entry, 'web_help_key', slugify(help_topic))
return help_topic
def can_read_topic(cmd_or_topic, account):
"""Check if an account or puppet has read access to a help entry.
Args:
cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test.
account: the account or puppet 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.
Even if this returns False, the entry will still be visible in the help index unless
`can_list_topic` is also returning False.
"""
if inherits_from(cmd_or_topic, "evennia.commands.command.Command"):
return cmd_or_topic.auto_help and cmd_or_topic.access(account, 'read', default=True)
else:
return cmd_or_topic.access(account, 'read', default=True)
def collect_topics(account):
"""Collect help topics from all sources (cmd/db/file).
Args:
account (Character or Account): Account or Character to collect help topics from.
Returns:
cmd_help_topics (dict): contains Command instances.
db_help_topics (dict): contains HelpEntry instances.
file_help_topics (dict): contains FileHelpEntry instances
"""
# collect commands of account and all puppets
# skip a command if an entry is recorded with the same topics, category and help entry
cmd_help_topics = []
if not str(account) == 'AnonymousUser':
# create list of account and account's puppets
puppets = account.db._playable_characters + [account]
# add the account's and puppets' commands to cmd_help_topics list
for puppet in puppets:
for cmdset in puppet.cmdset.get():
# removing doublets in cmdset, caused by cmdhandler
# having to allow doublet commands to manage exits etc.
cmdset.make_unique(puppet)
# retrieve all available commands and database / file-help topics.
# also check the 'cmd:' lock here
for cmd in cmdset:
# skip the command if the puppet does not have access
if not cmd.access(puppet, 'cmd'):
continue
# skip the command if the puppet does not have read access
if not can_read_topic(cmd, puppet):
continue
# skip the command if it's help entry already exists in the topic list
entry_exists = False
for verify_cmd in cmd_help_topics:
if (
verify_cmd.key and cmd.key and
verify_cmd.help_category == cmd.help_category and
verify_cmd.__doc__ == cmd.__doc__
):
entry_exists = True
break
if entry_exists:
continue
# add this command to the list
cmd_help_topics.append(cmd)
# Verify account has read access to filehelp entries and gather them into a dictionary
file_help_topics = {
topic.key.lower().strip(): topic
for topic in FILE_HELP_ENTRIES.all()
if can_read_topic(topic, account)
}
# Verify account has read access to database entries and gather them into a dictionary
db_help_topics = {
topic.key.lower().strip(): topic
for topic in HelpEntry.objects.all()
if can_read_topic(topic, account)
}
# Collect commands into a dictionary, read access verified at puppet level
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
}
return cmd_help_topics, db_help_topics, file_help_topics
class HelpMixin():
"""
This is a "mixin", a modifier of sorts.
@ -20,9 +164,6 @@ class HelpMixin(TypeclassMixin):
"""
# -- Django constructs --
model = HelpEntry
# -- Evennia constructs --
page_title = "Help"
@ -32,25 +173,24 @@ class HelpMixin(TypeclassMixin):
and other documentation that the current user is allowed to see.
Returns:
queryset (QuerySet): List of Help entries available to the user.
queryset (list): List of Help entries available to the user.
"""
account = self.request.user
# Get list of all HelpEntries
entries = self.typeclass.objects.all().iterator()
# collect all help entries
cmd_help_topics, db_help_topics, file_help_topics = collect_topics(account)
# Now figure out which ones the current user is allowed to see
bucket = [entry.id for entry in entries if entry.access(account, "view")]
# merge the 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())
# 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"))
)
# sort the entries
all_entries = sorted(all_entries, key=get_help_topic) # sort alphabetically
all_entries.sort(key=get_help_category) # group by categories
return filtered
return all_entries
class HelpListView(HelpMixin, ListView):
@ -68,15 +208,23 @@ 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.
"""
# -- Django constructs --
# the html template to use when contructing the detail page for a help topic
template_name = "website/help_detail.html"
@property
def page_title(self):
# Makes sure the page has a sensible 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
@ -88,19 +236,23 @@ class HelpDetailView(HelpMixin, EvenniaDetailView):
"""
context = super().get_context_data(**kwargs)
# get a full query set
full_set = self.get_queryset()
# Get the object in question
obj = self.get_object()
obj = self.get_object(full_set)
# 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
# filter non related categories from the query set
obj_category = get_help_category(obj)
category_set = []
for entry in full_set:
entry_category = get_help_category(entry)
if entry_category.lower() == obj_category.lower():
category_set.append(entry)
context["topic_list"] = category_set
# Find the index position of the given obj in the queryset
objs = list(queryset)
# Find the index position of the given obj in the category set
objs = list(category_set)
for i, x in enumerate(objs):
if obj is x:
break
@ -118,12 +270,16 @@ class HelpDetailView(HelpMixin, EvenniaDetailView):
except:
context["topic_previous"] = None
# Format the help entry using HTML instead of newlines
text = obj.db_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
# Get the help entry text
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 = strip_ansi(text) # remove ansii markups
context["entry_text"] = text.strip()
return context
@ -132,31 +288,45 @@ class HelpDetailView(HelpMixin, EvenniaDetailView):
Override of Django hook that retrieves an object by category and topic
instead of pk and slug.
Args:
queryset (list): A list of help entry objects.
Returns:
entry (HelpEntry): HelpEntry requested in the URL.
entry (HelpEntry, FileHelpEntry or Command): HelpEntry requested in the URL.
"""
if hasattr(self, 'obj'):
return getattr(self, 'obj', None)
# 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 slugify(entry_topic) == topic:
continue
# if the category also matches, object requested is found
entry_category = get_help_category(entry)
if slugify(entry_category) == category:
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."
)
else:
# cache the object if one was found
self.obj = obj
return obj