reports contrib

This commit is contained in:
Cal 2024-05-04 17:49:46 -06:00
parent 3b84ec1b42
commit 6986274222
5 changed files with 510 additions and 0 deletions

View file

@ -0,0 +1,123 @@
# In-Game Reporting System
This contrib provides an in-game reports system, handling bug reports, player reports, and idea submissions by default. It also supports adding your own types of reports, or removing any of the default report types.
Each type of report has its own command for submitting new reports, and an admin command is also provided for managing the reports through a menu.
## Installation
To install the reports contrib, just add the provided cmdset to your default AccountCmdSet:
```python
# in commands/default_cmdset.py
from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet
class AccountCmdSet(default_cmds.AccountCmdSet):
# ...
def at_cmdset_creation(self):
# ...
self.add(ReportsCmdSet)
```
The contrib also has two optional settings: `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`.
The `INGAME_REPORT_TYPES` setting is covered in detail in the section "Adding new types of reports".
The `INGAME_REPORT_STATUS_TAGS` setting is covered in the section "Managing reports".
## Usage
By default, the following report types are available:
* Bugs: Report bugs encountered during gameplay.
* Ideas: Submit suggestions for game improvement.
* Players: Report inappropriate player behavior.
Players can submit new reports through the command for each report type, and staff are given access to a report-management command and menu.
### Submitting reports
Players can submit reports using the following commands:
* `bug` - Files a bug report. An optional target can be included, making it easier for devs/builders to track down issues.
* `report` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default.
* `idea` - Submits a general suggestion, with no target. It also has an alias of `ideas` which allows you to view all of your submitted ideas.
### Managing reports
The `manage reports` command allows staff to review and manage the various types of reports. It dynamically aliases itself to include whatever types of reports you've configured for your game - by default, this means it makes `manage bugs`, `manage players`, and `manage ideas` available along with the default `manage reports`.
Aside from reading over existing reports, the menu allows you to change the status of any given report. By default, the contrib includes two different status tags: `in progress` and `closed`.
> Note: A report is created with no status tags, which is considered "open"
If you want a different set of statuses for your reports, you can define the `INGAME_REPORT_STATUS_TAGS` to your list of statuses.
**Example**
```python
# in server/conf/settings.py
# this will allow for the statuses of 'in progress', 'rejected', and 'completed', without the contrib-default of 'closed'
INGAME_REPORT_STATUS_TAGS = ('in progress', 'rejected', 'completed')
```
### Adding new types of reports
The contrib is designed to make adding new types of reports to the system as simple as possible, requiring only two steps.
#### Update your settings
The contrib optionally references `INGAME_REPORT_TYPES` in your settings.py to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting.
```python
# in server/conf/settings.py
# this will include the contrib's report types as well as a custom 'complaint' report type
INGAME_REPORT_TYPES = ('bugs', 'ideas', 'players', 'complaints')
```
You can also use this setting to remove any of the contrib's report types - the contrib will respect this setting when building its cmdset with no additional steps.
```python
# in server/conf/settings.py
# this redefines the setting to not include 'ideas', so the ideas command and reports won't be available
INGAME_REPORT_TYPES = ('bugs', 'players')
```
#### Create a new ReportCmd
`ReportCmdBase` is a parent command class which comes with the main functionality for submitting reports. Creating a new reporting command is as simple as inheriting from this class and defining a couple of class attributes.
* `key` - This is the same as for any other command, setting the command's usable key. It also acts as the report type if that isn't explicitly set.
* `report_type` - The type of report this command is for (e.g. `player`). You only need to set it if you want a different string from the key.
* `report_locks` - The locks you want applied to the created reports. Defaults to `"read:pperm(Admin)"`
* `success_msg` - The string which is sent to players after submitting a report of this type. Defaults to `"Your report has been filed."`
* `require_target`: Set to `True` if your report requires a target (e.g. player reports).
> Note: The contrib's own commands - `CmdBug`, `CmdIdea`, and `CmdReport` - are implemented the same way, so you can review them as examples.
Example:
```python
from evennia.contrib.base_systems.ingame_reports.reports import ReportCmdBase
class CmdCustomReport(ReportCmdBase):
"""
file a custom report
Usage:
customreport <message>
This is a custom report type.
"""
key = "customreport"
report_type = "custom"
success_message = "You have successfully filed a custom report."
```
Add this new command to your default cmdset to enable filing your new report type.

View file

@ -0,0 +1 @@
from .reports import ReportsCmdSet

View file

@ -0,0 +1,134 @@
"""
The report-management menu module.
"""
from django.conf import settings
from evennia.comms.models import Msg
from evennia.utils import logger
from evennia.utils.utils import crop, datetime_format, is_iter, iter_to_str
# the number of reports displayed on each page
_REPORTS_PER_PAGE = 10
_REPORT_STATUS_TAGS = ("closed", "in progress")
if hasattr(settings, "INGAME_REPORT_STATUS_TAGS"):
if is_iter(settings.INGAME_REPORT_STATUS_TAGS):
_REPORT_STATUS_TAGS = settings.INGAME_REPORT_STATUS_TAGS
else:
logger.log_warn(
"The 'INGAME_REPORT_STATUS_TAGS' setting must be an iterable of strings; falling back to defaults."
)
def menunode_list_reports(caller, raw_string, **kwargs):
"""Paginates and lists out reports for the provided hub"""
hub = caller.ndb._evmenu.hub
page = kwargs.get("page", 0)
start = page * _REPORTS_PER_PAGE
end = start + _REPORTS_PER_PAGE
report_slice = report_list[start:end]
hub_name = " ".join(hub.key.split("_")).title()
text = f"Managing {hub_name}"
if not (report_list := getattr(caller.ndb._evmenu, "report_list", None)):
report_list = Msg.objects.search_message(receiver=hub).order_by("db_date_created")
caller.ndb._evmenu.report_list = report_list
# allow the menu to filter print-outs by status
if kwargs.get("status"):
new_report_list = report_list.filter(db_tags__db_key=kwargs["status"])
# we don't filter reports if there are no reports under that filter
if not new_report_list:
text = f"(No {kwargs['status']} reports)\n{text}"
else:
report_list = new_report_list
text = f"Managing {kwargs['status']} {hub_name}"
else:
report_list = report_list.exclude(db_tags__db_key="closed")
# filter by lock access
report_list = [msg for msg in report_list if msg.access(caller, "read")]
# this will catch both no reports filed and no permissions
if not report_list:
return "There is nothing there for you to manage.", {}
options = [
{
"desc": f"{datetime_format(report.date_created)} - {crop(report.message, 50)}",
"goto": ("menunode_manage_report", {"report": report}),
}
for report in report_slice
]
options.append(
{
"key": ("|uF|nilter by status", "filter", "status", "f"),
"goto": "menunode_choose_filter",
}
)
if start > 0:
options.append(
{
"key": (f"|uP|nrevious {_REPORTS_PER_PAGE}", "previous", "prev", "p"),
"goto": (
"menunode_list_reports",
{"page": max(start - _REPORTS_PER_PAGE, 0) // _REPORTS_PER_PAGE},
),
}
)
if end < len(report_list):
options.append(
{
"key": (f"|uN|next {_REPORTS_PER_PAGE}", "next", "n"),
"goto": (
"menunode_list_reports",
{"page": (start + _REPORTS_PER_PAGE) // _REPORTS_PER_PAGE},
),
}
)
return text, options
def menunode_choose_filter(caller, raw_string, **kwargs):
"""apply or clear a status filter to the main report view"""
text = "View which reports?"
# options for all the possible statuses
options = [
{"desc": status, "goto": ("menunode_list_reports", {"status": status})}
for status in _REPORT_STATUS_TAGS
]
# no filter
options.append({"desc": "All open reports", "goto": "menunode_list_reports"})
return text, options
def _report_toggle_tag(caller, raw_string, report, tag, **kwargs):
"""goto callable to toggle a status tag on or off"""
if tag in report.tags.all():
report.tags.remove(tag)
else:
report.tags.add(tag)
return ("menunode_manage_report", {"report": report})
def menunode_manage_report(caller, raw_string, report, **kwargs):
"""
Read out the full report text and targets, and allow for changing the report's status.
"""
receivers = [r for r in report.receivers if r != caller.ndb._evmenu.hub]
text = f"""\
{report.message}
{datetime_format(report.date_created)} by {iter_to_str(report.senders)}{' about '+iter_to_str(r.get_display_name(caller) for r in receivers) if receivers else ''}
{iter_to_str(report.tags.all())}"""
options = []
for tag in _REPORT_STATUS_TAGS:
options.append(
{
"desc": f"{'Unmark' if tag in report.tags.all() else 'Mark' } as {tag}",
"goto": (_report_toggle_tag, {"report": report, "tag": tag}),
}
)
options.append({"desc": f"Manage another report", "goto": "menunode_list_reports"})
return text, options

View file

@ -0,0 +1,251 @@
"""
In-Game Reports
"""
# TODO: docstring
from django.conf import settings
from evennia import CmdSet
from evennia.utils import create, evmenu, logger, search
from evennia.utils.utils import datetime_format, is_iter, iter_to_str
from evennia.commands.default.muxcommand import MuxCommand
from evennia.comms.models import Msg
from . import menu
# TODO: use actual default command class
_DEFAULT_COMMAND_CLASS = MuxCommand
# the default report types
_REPORT_TYPES = ("bugs", "ideas", "players")
if hasattr(settings, "INGAME_REPORT_TYPES"):
if is_iter(settings.INGAME_REPORT_TYPES):
_REPORT_TYPES = settings.INGAME_REPORT_TYPES
else:
logger.log_warn(
"The 'INGAME_REPORT_TYPES' setting must be an iterable of strings; falling back to defaults."
)
def _get_report_hub(report_type):
"""
A helper function to retrieve the global script which acts as the hub for a given report type.
Args:
report_type (str): The category of reports to retrieve the script for.
Returns:
Script or None: The global script, or None if it couldn't be retrieved or created
Note: If no matching valid script exists, this function will attempt to create it.
"""
hub_key = f"{report_type}_reports"
# NOTE: due to a regression in GLOBAL_SCRIPTS, we use search_script instead of the container
if not (hub := search.search_script(hub_key)):
hub = create.create_script(key=hub_key)
return hub or None
class CmdManageReports(_DEFAULT_COMMAND_CLASS):
"""
manage the various reports
Usage:
manage [report type]
Available report types:
bugs
ideas
players
Initializes a menu for reviewing and changing the status of current reports.
"""
key = "manage reports"
aliases = tuple(f"manage {report_type}" for report_type in _REPORT_TYPES)
locks = "cmd:pperm(Admin)"
def get_help(self):
"""Returns a help string containing the configured available report types"""
report_types = iter_to_str("\n ".join(_REPORT_TYPES))
helptext = f"""\
manage the various reports
Usage:
manage [report type]
Available report types:
{report_types}
Initializes a menu for reviewing and changing the status of current reports.
"""
return helptext
def func(self):
_, report_type = self.cmdstring.split()[-1]
if report_type == "reports":
report_type = "players"
if report_type not in _REPORT_TYPES:
self.msg(f"'{report_type}' is not a valid report category.")
return
# remove the trailing s, just so everything reads nicer
report_type = report_type[:-1]
hub = _get_report_hub(report_type)
if not hub:
self.msg("You cannot manage that.")
evmenu.EvMenu(self.account, menu, startnode="menunode_list_reports", hub=hub, persistent=True)
class ReportCmdBase(_DEFAULT_COMMAND_CLASS):
"""
A parent class for creating report commands. This help text may be displayed if
your command's help text is not properly configured.
"""
help_category = "reports"
# defines what locks the reports generated by this command will have set
report_locks = "read:pperm(Admin)"
# determines if the report can be filed without a target
require_target = False
# the message sent to the reporter after the report has been created
success_msg = "Your report has been filed."
report_type = None
def at_pre_cmd(self):
"""validate that the needed hub script exists - if not, cancel the command"""
hub = _get_report_hub(self.report_type or self.key)
if not hub:
# a return value of True from `at_pre_cmd` cancels the command
return True
self.hub = hub
return super().at_pre_cmd()
def func(self):
hub = self.hub
if not self.args:
self.msg("You must provide a message.")
return
if self.rhs:
message = self.rhs
target = self.lhs
else:
message = self.lhs
target = None
if target:
target = self.caller.search(target)
if not target:
return
elif self.require_target:
self.msg("You must include a target.")
return
receivers = [hub]
if target:
receivers.append(target)
if create.create_message(
self.account, message, receivers=receivers, locks=self.report_locks, tags=["report"]
):
# the report Msg was successfully created
self.msg(self.success_msg)
else:
# something went wrong
self.msg(
"Something went wrong creating your report. Please try again later or contact staff directly."
)
# The commands below are the usable reporting commands
class CmdBug(ReportCmdBase):
"""
file a bug
Usage:
bug [target] = message
Note: If a specific object, location or character is bugged, please target it for the report.
Examples:
bug hammer = This doesn't work as a crafting tool but it should
bug every time I go through a door I get the message twice
"""
key = "bug"
report_locks = "read:pperm(Developer)"
class CmdReport(ReportCmdBase):
"""
report a player
Usage:
report player = message
All player reports will be reviewed.
"""
key = "report"
report_type = "player"
require_target = True
class CmdIdea(ReportCmdBase):
"""
submit a suggestion
Usage:
ideas
idea <message>
Example:
idea wouldn't it be cool if we had horses we could ride
"""
key = "idea"
aliases = ("ideas",)
report_locks = "read:pperm(Builder)"
def func(self):
# we add an extra feature to this command, allowing you to see all your submitted ideas
if self.cmdstring == "ideas":
# list your ideas
if (
ideas := Msg.objects.search_message(sender=self.account, receiver=self.hub)
.order_by("-db_date_created")
.exclude(db_tags__db_key="closed")
):
# todo: use a paginated menu
self.msg(
"Ideas you've submitted:\n "
+ "\n ".join(
f"|w{item.message}|n (submitted {datetime_format(item.date_created)})"
for item in ideas
)
)
else:
self.msg("You have no open suggestions.")
return
# proceed to do the normal report-command functionality
super().func()
class ReportsCmdSet(CmdSet):
key = "Reports CmdSet"
def at_cmdset_creation(self):
super().at_cmdset_creation()
if "bugs" in _REPORT_TYPES:
self.add(CmdBug)
if "ideas" in _REPORT_TYPES:
self.add(CmdIdea)
if "players" in _REPORT_TYPES:
self.add(CmdReport)
self.add(CmdManageReports)

View file

@ -0,0 +1 @@
# TODO: write tests