Largely rewrote and refactored the help system.

The help entry database structure has changed! You have to resync or purge
your database or your will get problems!

New features:
* Help entry access now fully controlled by evennia permissions
* Categories for each help entry
* All entries are created dynamically, with a See also: footer calculated
  after the current state of the database.
* Indexes and topic list calculated on the fly (alphabetically/after category)
* Added auto-help help entries for all default commands.
* Only shows commands _actually implemented_ - MUX help db moved into 'MUX' category
  which is not shown by default.
* More powerful auto-help markup - supports categories and permissions (and inheritance).
* Global on/off switch for auto-help, when entering production
* Auto_help_override switch for selectively activating auto-help when developing
  new commands (like the old system).
* Refactored State help system; no more risk of overwriting global help entries.
* State help now defers to main help db when no match found; makes system more transparent.
* State help entries also support categories/permissions (state categories are not
  used much though).

Other updates:
* Added more commands to the batch processor
* Many bug-fixes.

/Griatch
This commit is contained in:
Griatch 2009-10-14 18:15:15 +00:00
parent 46e2cd3ecb
commit 8074617285
27 changed files with 1995 additions and 1072 deletions

411
src/helpsys/helpsystem.py Normal file
View file

@ -0,0 +1,411 @@
"""
Support functions for the help system.
Allows adding help to the data base from inside the mud as
well as creating auto-docs of commands based on their doc strings.
The system supports help-markup for multiple help entries as well
as a dynamically updating help index.
"""
import textwrap
from django.conf import settings
from src.helpsys.models import HelpEntry
from src import logger
from src import defines_global
class EditHelp(object):
"""
This sets up an object able to perform normal editing
operations on the help database.
"""
def __init__(self, indent=4, width=70):
"""
We check if auto-help is active or not and
set some formatting options.
"""
self.indent = indent # indentation of help text
self.width = width # width of help text
def format_help_text(self, help_text):
"""
This formats the help entry text for proper left-side indentation.
The first line is adjusted to the proper indentation and the
subsequent lines are then adjusted proportionally to the first;
so indentation relative this first line remains intact.
"""
lines = help_text.expandtabs().splitlines()
# strip empty lines above and below the text
while True:
if lines and not lines[0].strip():
lines.pop(0)
else:
break
while True:
if lines and not lines[-1].strip():
lines.pop()
else:
break
if not lines:
return ""
# produce a list of the indentations of each line initially
indentlist = [len(line) - len(line.lstrip()) for line in lines]
# use the first line to set the shift
lineshift = indentlist[0] - self.indent
# shift everything to the left
indentlist = [max(self.indent, indent-lineshift) for indent in indentlist]
trimmed = []
for il, line in enumerate(lines):
indentstr = " " * indentlist[il]
trimmed.append("%s%s" % (indentstr, line.strip()))
return "\n".join(trimmed)
def parse_markup_header(self, subtopic_header):
"""
The possible markup headers for splitting the help into sections are:
[[TopicTitle]]
[[TopicTitle,category]]
[[TopicTitle(perm1,perm2)]]
[[TopicTitle,category(perm1,perm2)]]
"""
subtitle = ""
subcategory = ""
subpermissions = ()
#identifying the header parts. The header can max have three parts:
# topicname, category (perm1,perm2,...)
try:
# find the permission tuple
lindex = subtopic_header.index('(')
rindex = subtopic_header.index(')')
if lindex < rindex:
permtuple = subtopic_header[lindex+1:rindex]
subpermissions = tuple([p.strip()
for p in permtuple.split(',')])
subtopic_header = subtopic_header[:lindex]
except ValueError:
# no permission tuple found
pass
# see if we have a name, category pair.
try:
subtitle, subcategory = subtopic_header.split(',')
subtitle, subcategory = subtitle.strip(), subcategory.strip()
except ValueError:
subtitle = subtopic_header.strip()
# we are done, return a tuple with the results
return ( subtitle, subcategory, subpermissions )
def format_help_entry(self, helptopic, category, helptext, permissions=None):
"""
helptopic (string) - name of the full help entry
helptext (string) - the help entry (may contain sections)
permissions (tuple) - tuple with permission/group names
defined for the entire help entry.
(markup permissions override those)
Handles help markup in order to split help into subsections.
These markup markers will be assumed to start a new line, regardless
of where they are located in the help entry. If no permission string
tuple and/or category is given, the overall permission/category of
the entire help entry is used.
"""
# sanitize input
topics = []
if '[[' not in helptext:
formatted_text = self.format_help_text(helptext)
topics.append((helptopic, category,
formatted_text, permissions))
return topics
subtopics = helptext.split('[[')
if subtopics[0]:
# the very first entry (before any markup) is the normal
# help entry for the helptopic at hand.
formatted_text = self.format_help_text(subtopics[0])
topics.append((helptopic, category, formatted_text, permissions))
for subtopic in subtopics[1:]:
# handle all extra topics designated with markup
try:
subtopic_header, subtopic_text = subtopic.split(']]', 1)
except ValueError:
# if we have no ending, the entry is malformed and
# we ignore this entry (better than overwriting
# something in the database).
logger.log_errmsg("Malformed help markup in %s: '%s'\n (missing end ']]' )" % \
(helptopic, subtopic))
continue
# parse and format the help entry parts
subtopic_header = self.parse_markup_header(subtopic_header)
if not subtopic_header[0]:
# we require a topic title.
logger.log_errmsg("Malformed help markup in '%s': Missing title." % subtopic_header)
return
# parse the header and use defaults
subtopic_name = subtopic_header[0]
subtopic_category = subtopic_header[1]
subtopic_text = self.format_help_text(subtopic_text)
subtopic_permissions = subtopic_header[2]
if not subtopic_category:
# no category set; inherit from main topic
subtopic_category = category
if not subtopic_permissions:
# no permissions set; inherit from main topic
subtopic_permissions = permissions
# We have a finished topic, add it to the list as a topic tuple.
topics.append((subtopic_name, subtopic_category,
subtopic_text, subtopic_permissions))
return topics
def create_help(self, newtopic):
"""
Add a help entry to the database, replace an old one if it exists.
topic (tuple) - this is a formatted tuple of data as prepared
by format_help_entry, on the form (title, category, text, (perm_tuple))
"""
#sanity checks;
topicname = newtopic[0]
category = newtopic[1]
entrytext = newtopic[2]
permissions = newtopic[3]
if not (topicname or entrytext):
# don't create anything if there we
# are missing vital parts
return
if not category:
# this will force the default
category = "General"
if permissions:
# the permissions tuple might be mangled;
# make sure we build a string properly.
if type(permissions) != type(tuple()):
permissions = "%s" % permissions
else:
permissions = ", ".join(permissions)
else:
permissions = ""
# check if the help topic already exist.
oldtopic = HelpEntry.objects.filter(topicname__iexact=newtopic[0])
if oldtopic:
#replace an old help file
topic = oldtopic[0]
topic.category = category
topic.entrytext = entrytext
topic.canview = permissions
topic.save()
else:
#we have a new topic - create a new help object
new_entry = HelpEntry(topicname=topicname,
category=category,
entrytext=entrytext,
canview=permissions)
new_entry.save()
def add_help_auto(self, topicstr, category, entrytext, permissions=()):
"""
This is used by the auto_help system to add help one or more
help entries to the system.
"""
# sanity checks
if permissions and type(permissions) != type(tuple()):
string = "Auto-Help: malformed perm_tuple %s: %s -> %s (fixed)" % \
(topicstr,permissions, (permissions,))
logger.log_errmsg(string)
permissions = (permissions,)
# identify markup and do nice formatting as well as eventual
# related entries to the help entries.
logger.log_infomsg("auto-help in: %s %s %s %s" % (topicstr, category, entrytext, permissions))
topics = self.format_help_entry(topicstr, category,
entrytext, permissions)
logger.log_infomsg("auto-help: %s -> %s" % (topicstr,topics))
# create the help entries:
if topics:
for topic in topics:
self.create_help(topic)
def add_help_manual(self, pobject, topicstr, category,
entrytext, permissions=(), force=False):
"""
This is used when a player wants to add a help entry to the database
manually (most often from inside the game)
force - this is given by the player and forces an overwrite also if the
entry already exists or there are multiple similar matches to
the entry.
"""
# permission check:
if not (pobject.is_superuser() or pobject.has_perm("helpsys.add_help")):
pobject.emit_to(defines_global.NOPERMS_MSG)
return None
# do a more fuzzy search to warn in case in case we are misspelling.
topic = HelpEntry.objects.find_topicmatch(pobject, topicstr)
if topic and not force:
return topic
self.add_help_auto(topicstr, category, entrytext, permissions)
pobject.emit_to("Added/appended help topic '%s'." % topicstr)
def del_help_auto(self, topicstr):
"""
Delete a help entry from the data base. Automatic version.
"""
topic = HelpEntry.objects.filter(topicname__iexact=topicstr)
if topic:
topic[0].delete()
def del_help_manual(self, pobject, topicstr):
"""
Deletes an entry from the database. Interactive version.
Note that it makes no sense to delete auto-added help entries this way since
they will be re-added on the next @reload. This is mostly useful for cleaning
the database from doublet or orphaned entries, or when auto-help is turned off.
"""
# find topic with permission checks
if not (pobject.is_superuser() or pobject.has_perm("helpsys.del_help")):
pobject.emit_to(defines_global.NOPERMS_MSG)
return None
topic = HelpEntry.objects.find_topicmatch(pobject, topicstr)
if not topic or len(topic) > 1:
return topic
# we have an exact match. Delete topic.
topic[0].delete()
pobject.emit_to("Help entry '%s' deleted." % topicstr)
def homogenize_database(self, category):
"""
This sets the entire help database to one category.
It can be used to mark an initially loaded help database
in a particular category, for later filtering.
In evennia dev version, this is done with MUX help database.
"""
entries = HelpEntry.objects.all()
for entry in entries:
entry.category = category
entry.save()
logger.log_infomsg("Help database homogenized to category %s" % category)
def autoclean_database(self, topiclist):
"""
This syncs the entire help database against a reference topic
list, deleting non-used or duplicate help entries that can be
the result of auto-help misspellings etc.
"""
pass
class ViewHelp(object):
"""
This class contains ways to view the
help database in a dynamical fashion.
"""
def __init__(self, indent=4, width=78, category_cols=4, entry_cols=6):
"""
indent (int) - number of spaces to indent tables with
width (int) - width of index tables
category_cols (int) - number of collumns per row for
category tables
entry_cols (int) - number of collumns per row for help entries
"""
self.width = width
self.indent = indent
self.category_cols = category_cols
self.entry_cols = entry_cols
self.show_related = settings.HELP_SHOW_RELATED
def make_table(self, items, cols):
"""
This takes a list of string items and displays them in collumn order,
(sorted horizontally-first), ie
A A A A
A B B B
B B C C
C C
cols is the number of collumns to format.
"""
items.sort()
if not items or not cols:
return []
length = len(items)
# split the list into sublists of length cols
rows = [items[i:i+cols] for i in xrange(0, length, cols)]
# build the table
string = ""
for row in rows:
string += self.indent * " " + ", ".join(row) + "\n"
return string
def index_full(self, pobject):
"""
This lists all available topics in the help index,
ordered after category.
The MUX category is not shown, it is for development
reference only.
"""
entries = HelpEntry.objects.all()
categories = [e.category for e in entries if e.category != 'MUX']
categories = list(set(categories)) # make list unique
categories.sort()
table = ""
for category in categories:
topics = [e.topicname.lower() for e in entries.filter(category__iexact=category)
if e.can_view(pobject)]
# pretty-printing the list
header = "--- Topics in category %s:" % category
nl = self.width - len(header)
if not topics:
text = self.indent*" " + "[There are no topics relevant to you in this category.]\n\r"
else:
text = self.make_table(topics, self.entry_cols)
table += "\r\n%s%s\n\r\n\r%s" % (header, "-"*nl, text)
return table
def index_categories(self):
"""
This lists all categories defined in the help index.
"""
entries = HelpEntry.objects.all()
categories = [e.category for e in entries]
categories = list(set(categories)) # make list unique
return self.make_table(categories, self.category_cols)
def index_category(self, pobject, category):
"""
List the help entries within a certain category
"""
entries = HelpEntry.objects.find_topics_with_category(pobject, category)
if not entries:
return []
# filter out those we can actually view
helptopics = [e.topicname.lower() for e in entries if e.can_view(pobject)]
if not helptopics:
# we don't have permission to view anything in this category
return " [There are no topics relevant to you in this category.]\n\r"
return self.make_table(helptopics, self.entry_cols)
def suggest_help(self, pobject, topic):
"""
This goes through the help database, searching for relatively
close matches to this topic. If those are found, they are
added as a nice footer to the end of the topic entry.
"""
if not self.show_related:
return None
topicname = topic.topicname
return HelpEntry.objects.find_topicsuggestions(pobject, topicname)
# Object instances
edithelp = EditHelp(indent=3,
width=80)
viewhelp = ViewHelp(indent=3,
width=80,
category_cols=4,
entry_cols=4)

View file

@ -1,230 +0,0 @@
"""
Support commands for a more advanced help system.
Allows adding help to the data base from inside the mud as
well as creating auto-docs of commands based on their doc strings.
The system supports help-markup for multiple help entries as well
as a dynamically updating help index.
"""
from django.contrib.auth.models import User
from src.helpsys.models import HelpEntry
from src.ansi import ANSITable
#
# Helper functions
#
def _privileged_help_search(topicstr):
"""
searches the topic data base without needing to know who calls it. Needed
for autohelp functionality. Will show all help entries, also those set to staff
only.
"""
if topicstr.isdigit():
t_query = HelpEntry.objects.filter(id=topicstr)
else:
exact_match = HelpEntry.objects.filter(topicname__iexact=topicstr)
if exact_match:
t_query = exact_match
else:
t_query = HelpEntry.objects.filter(topicname__istartswith=topicstr)
return t_query
def _create_help(topicstr, entrytext, staff_only=False, force_create=False,
pobject=None, noauth=False):
"""
Add a help entry to the database, replace an old one if it exists.
Note - noauth=True will bypass permission checks, so do not use this from
inside mud, it is needed by the autohelp system only.
"""
if noauth:
#do not check permissions (for autohelp)
topic = _privileged_help_search(topicstr)
elif pobject:
#checks access rights before searching (this should have been
#done already at the command level)
if not pobject.has_perm("helpsys.add_help"): return []
topic = HelpEntry.objects.find_topicmatch(pobject, topicstr)
else:
return []
if len(topic) == 1:
#replace an old help file
topic = topic[0]
topic.entrytext = entrytext
topic.staff_only = staff_only
topic.save()
return [topic]
elif len(topic) > 1 and not force_create:
#a partial match, return it for inspection.
return topic
else:
#we have a new topic - create a new help object
new_entry = HelpEntry(topicname=topicstr,
entrytext=entrytext,
staff_only=staff_only)
new_entry.save()
return [new_entry]
def handle_help_markup(topicstr, entrytext, staff_only, identifier="<<TOPIC:"):
"""
Handle help markup in order to split help into subsections.
Handles markup of the form <<TOPIC:STAFF:TopicTitle>> and
<<TOPIC:ALL:TopicTitle>> to override the staff_only flag on a per-subtopic
basis.
"""
topic_list = entrytext.split(identifier)
topic_dict = {}
staff_dict = {}
for txt in topic_list:
txt = txt.rstrip()
if txt.count('>>'):
topic, text = txt.split('>>',1)
text = text.rstrip()
topic = topic.lower()
if topic in topic_dict.keys():
#do not allow multiple topics of the same name
return {}, []
if 'all:' in topic:
topic = topic[4:]
staff_dict[topic] = False
elif 'staff:' in topic:
topic = topic[6:]
staff_dict[topic] = True
else:
staff_dict[topic] = staff_only
topic_dict[topic] = text
else:
#no markup, just add the entry as-is
topic = topicstr.lower()
topic_dict[topic] = txt
staff_dict[topic] = staff_only
return topic_dict, staff_dict
def format_footer(top, text, topic_dict, staff_dict):
"""
Formats the subtopic with a 'Related Topics:' footer. If mixed
staff-only flags are set, those help entries without the staff-only flag
will not see staff-only help files recommended in the footer. This allows
to separate out the staff-only help switches etc into a separate
help file so as not to confuse normal players.
"""
if text:
#only include non-staff related footers to non-staff commands
if staff_dict[top]:
other_topics = other_topics = filter(lambda o: o != top, topic_dict.keys())
else:
other_topics = other_topics = filter(lambda o: o != top and not staff_dict[o],
topic_dict.keys())
if other_topics:
footer = ANSITable.ansi['normal'] + "\n\r\n\r Related Topics: "
for t in other_topics:
footer += t + ', '
footer = footer[:-2] + '.'
return text + footer
else:
return text
else:
return False
#
# Access functions
#
def add_help(topicstr, entrytext, staff_only=False, force_create=False,
pobject=None, auto_help=False):
"""
Add a help topic to the database. This is also usable by autohelp, with auto=True.
Allows <<TOPIC:TopicTitle>> markup in the help text, to automatically spawn
subtopics. For creating mixed staff/ordinary subtopics, the <<TOPIC:STAFF:TopicTitle>> and
<<TOPIC:ALL:TopicTitle>> commands can override the overall staff_only setting for
that entry only.
"""
identifier = '<<TOPIC:'
if identifier in entrytext:
#There is markup in the entry, so we should split the doc into separate subtopics
topic_dict, staff_dict = handle_help_markup(topicstr, entrytext,
staff_only, identifier)
topics = []
for topic, text in topic_dict.items():
#format with nice footer
entry = format_footer(topic, text, topic_dict, staff_dict)
if entry:
#create the subtopic
newtopic = _create_help(topic, entry,staff_only=staff_dict[topic],
force_create=force_create,pobject=pobject,noauth=auto_help)
topics.extend(newtopic)
return topics
elif entrytext:
#if there were no topic sections, just create the help entry as normal
return _create_help(topicstr.lower(),entrytext,staff_only=staff_only,
force_create=force_create,pobject=pobject,noauth=auto_help)
def del_help(pobject,topicstr):
"""
Delete a help entry from the data base.
Note that it makes no sense to delete auto-added help entries this way since
they will just be re-added on the next @reload. Delete such entries by turning
off their auto-help functionality first.
"""
#find topic with permission checks
if not pobject.is_staff(): return []
topic = HelpEntry.objects.find_topicmatch(pobject, topicstr)
if topic:
if len(topic) == 1:
#delete topic
topic.delete()
return True
else:
return topic
else:
return []
def get_help_index(pobject,filter=None):
"""
Dynamically builds a help index depending on who asks for it, so
normal players won't see staff-only help files, for example.
The filter parameter allows staff to limit their view of the help index
no filter (default) - view all help files, staff and non-staff
filter='staff' - view only staff-specific help files
filter='player' - view only those files visible to all
"""
if pobject.has_perm("helpsys.staff_help"):
if filter == 'staff':
helpentries = HelpEntry.objects.filter(staff_only=True).order_by('topicname')
elif filter == 'player':
helpentries = HelpEntry.objects.filter(staff_only=False).order_by('topicname')
else:
helpentries = HelpEntry.objects.all().order_by('topicname')
else:
helpentries = HelpEntry.objects.filter(staff_only=False).order_by('topicname')
if not helpentries:
pobject.emit_to("No help entries found.")
return
topics = [entry.topicname for entry in helpentries]
#format help entries into suitable alphabetized collumns.
percollumn = 8
s = ""
i = 0
while True:
i += 1
try:
top = topics.pop(0)
s+= " %s " % top
if i%percollumn == 0: s += '\n\r'
except IndexError:
break
s += " (%i entries)" % (i-1)
pobject.emit_to(s)

View file

@ -4,35 +4,56 @@ Custom manager for HelpEntry objects.
from django.db import models
class HelpEntryManager(models.Manager):
def find_topicmatch(self, pobject, topicstr):
"""
This implements different ways to search for help entries.
"""
def find_topicmatch(self, pobject, topicstr, exact=False):
"""
Searches for matching topics based on player's input.
"""
is_staff = pobject.is_staff()
"""
if topicstr.isdigit():
t_query = self.filter(id=topicstr)
else:
exact_match = self.filter(topicname__iexact=topicstr)
if exact_match:
t_query = exact_match
else:
t_query = self.filter(topicname__istartswith=topicstr)
if not is_staff:
return t_query.exclude(staff_only=1)
return self.filter(id=topicstr)
t_query = self.filter(topicname__iexact=topicstr)
if not t_query and not exact:
t_query = self.filter(topicname__istartswith=topicstr)
# check permissions
t_query = [topic for topic in t_query if topic.can_view(pobject)]
return t_query
def find_topicsuggestions(self, pobject, topicstr):
"""
Do a fuzzier "contains" match.
"""
is_staff = pobject.is_staff()
t_query = self.filter(topicname__icontains=topicstr)
if not is_staff:
return t_query.exclude(staff_only=1)
return t_query
Do a fuzzy match, preferably within the category of the
current topic.
"""
basetopic = self.filter(topicname__iexact=topicstr)
if not basetopic:
# this topic does not exist; just reply partial
# matches to the string
topics = self.filter(topicname__icontains=topicstr)
return [topic for topic in topics if topic.can_view(pobject)]
# we know that the topic exists, try to find similar ones within
# its category.
basetopic = basetopic[0]
category = basetopic.category
topics = []
#remove the @
crop = topicstr.lstrip('@')
# first we filter for matches with the full name
topics = self.filter(category__iexact=category).filter(topicname__icontains=crop)
if len(crop) > 4:
# next search with a cropped version of the command.
ttemp = self.filter(category__iexact=category).filter(topicname__icontains=crop[:4])
ttemp = [topic for topic in ttemp if topic not in topics]
topics = list(topics) + list(ttemp)
# we need to clean away the given help entry.
return [topic for topic in topics if topic.topicname.lower() != topicstr.lower()]
def find_topics_with_category(self, pobject, category):
"""
Search topics having a particular category
"""
t_query = self.filter(category__iexact=category)
return [topic for topic in t_query if topic.can_view(pobject)]

View file

@ -11,12 +11,21 @@ class HelpEntry(models.Model):
A generic help entry.
"""
topicname = models.CharField(max_length=255, unique=True)
entrytext = models.TextField(blank=True, null=True)
staff_only = models.BooleanField(default=False)
category = models.CharField(max_length=255, default="General")
canview = models.CharField(max_length=255, blank=True)
entrytext = models.TextField(blank=True)
#deprecated, only here to allow MUX helpfile load.
staff_only = models.BooleanField(default=False)
objects = HelpEntryManager()
class Meta:
"""
Permissions here defines access to modifying help
entries etc, not which entries can be viewed (that
is controlled by the canview field).
"""
verbose_name_plural = "Help entries"
ordering = ['topicname']
permissions = settings.PERM_HELPSYS
@ -29,7 +38,24 @@ class HelpEntry(models.Model):
Returns the topic's name.
"""
return self.topicname
def get_category(self):
"""
Returns the category of this help entry.
"""
return self.category
def can_view(self, pobject):
"""
Check if the pobject has the necessary permission/group
to view this help entry.
"""
perm = self.canview.split(',')
if not perm or (len(perm) == 1 and not perm[0]) or \
pobject.has_perm("helpsys.admin_help"):
return True
return pobject.has_perm_list(perm)
def get_entrytext_ingame(self):
"""
Gets the entry text for in-game viewing.