Merge pull request #3531 from InspectorCaracal/reports-contrib

Contrib for an in-game reports system
This commit is contained in:
Griatch 2024-07-13 14:20:27 +02:00 committed by GitHub
commit 096100ee55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 664 additions and 0 deletions

View file

@ -0,0 +1,128 @@
# 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 <text>` - Files a bug report. An optional target can be included - `bug <target> = <text>` - making it easier for devs/builders to track down issues.
* `report <player> = <text>` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default.
* `idea <text>` - 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 by launching a management menu.
This command will dynamically add aliases to itself based on the types of reports available, with each command string launching a menu for that particular report type. The aliases are built on the pattern `manage <report type>s` - by default, this means it makes `manage bugs`, `manage players`, and `manage ideas` available along with the default `manage reports`, and that e.g. `manage bugs` will launch the management menu for `bug`-type 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:
1. Update your settings file to include an `INGAME_REPORT_TYPES` setting.
2. Create and add a new `ReportCmd` to your command set.
#### 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 type 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,315 @@
"""
In-Game Reporting System
This contrib provides an in-game reporting system, with player-facing commands and a staff
management interface.
# Installation
To install, just add the provided cmdset to your default AccountCmdSet:
# 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)
# Features
The contrib provides three commands by default and their associated report types: `CmdBug`, `CmdIdea`,
and `CmdReport` (which is for reporting other players).
The `ReportCmdBase` class holds most of the functionality for creating new reports, providing a
convenient parent class for adding your own categories of reports.
The contrib can be further configured through two settings, `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`
"""
from django.conf import settings
from evennia import CmdSet
from evennia.utils import create, evmenu, logger, search
from evennia.utils.utils import class_from_module, datetime_format, is_iter, iter_to_str
from evennia.commands.default.muxcommand import MuxCommand
from evennia.comms.models import Msg
from . import menu
_DEFAULT_COMMAND_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
# 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."
# the report type for this command, if different from the key
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 parse(self):
"""
Parse the target and message out of the arguments.
Override if you want different syntax, but make sure to assign `report_message` and `target_str`.
"""
# do the base MuxCommand parsing first
super().parse()
# split out the report message and target strings
if self.rhs:
self.report_message = self.rhs
self.target_str = self.lhs
else:
self.report_message = self.lhs
self.target_str = ""
def target_search(self, searchterm, **kwargs):
"""
Search for a target that matches the given search term. By default, does a normal search via the
caller - a local object search for a Character, or an account search for an Account.
Args:
searchterm (str) - The string to search for
Returns:
result (Object, Account, or None) - the result of the search
"""
return self.caller.search(searchterm)
def create_report(self, *args, **kwargs):
"""
Creates the report. By default, this creates a Msg with any provided args and kwargs.
Returns:
success (bool) - True if the report was created successfully, or False if there was an issue.
"""
return create.create_message(*args, **kwargs)
def func(self):
hub = self.hub
if not self.args:
self.msg("You must provide a message.")
return
target = None
if self.target_str:
target = self.target_search(self.target_str)
if not target:
return
elif self.require_target:
self.msg("You must include a target.")
return
receivers = [hub]
if target:
receivers.append(target)
if self.create_report(
self.account, self.report_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
account_caller = 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)"
success_msg = "Thank you for your suggestion!"
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,86 @@
from unittest.mock import Mock, patch, MagicMock
from evennia.utils import create
from evennia.comms.models import TempMsg
from evennia.utils.test_resources import EvenniaCommandTest
from . import menu, reports
class _MockQuerySet(list):
def order_by(self, *args, **kwargs):
return self
def exclude(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def _mock_pre(cmdobj):
"""helper to mock at_pre_cmd"""
cmdobj.hub = Mock()
class TestReportCommands(EvenniaCommandTest):
@patch.object(create, "create_message", new=MagicMock())
def test_report_cmd_base(self):
"""verify that the base command functionality works"""
cmd = reports.ReportCmdBase
# avoid test side-effects
with patch.object(cmd, "at_pre_cmd", new=_mock_pre) as _:
# no arguments
self.call(cmd(), "", "You must provide a message.")
# arguments, no target, no target required
self.call(cmd(), "test", "Your report has been filed.")
# arguments, custom success message
custom_success = "custom success message"
cmd.success_msg = custom_success
self.call(cmd(), "test", custom_success)
# arguments, no target, target required
cmd.require_target = True
self.call(cmd(), "test", "You must include a target.")
@patch.object(create, "create_message", new=MagicMock())
@patch.object(reports, "datetime_format", return_value="now")
def test_ideas_list(self, mock_datetime_format):
cmd = reports.CmdIdea
fake_ideas = _MockQuerySet([TempMsg(message=f"idea {i+1}") for i in range(3)])
expected = """\
Ideas you've submitted:
idea 1 (submitted now)
idea 2 (submitted now)
idea 3 (submitted now)
"""
with patch.object(cmd, "at_pre_cmd", new=_mock_pre) as _:
# submitting an idea
self.call(cmd(), "", "You must provide a message.")
# arguments, no target, no target required
self.call(cmd(), "test", "Thank you for your suggestion!")
# viewing your submitted ideas
with patch.object(reports.Msg.objects, "search_message", return_value=fake_ideas):
self.call(cmd(), "", cmdstring="ideas", msg=expected)
@patch.object(reports.evmenu, "EvMenu")
def test_cmd_manage_reports(self, evmenu_mock):
cmd = reports.CmdManageReports
hub = Mock()
with patch.object(reports, "_get_report_hub", return_value=hub) as _:
# invalid report type fails
self.call(
cmd(), "", cmdstring="manage custom", msg="'custom' is not a valid report category."
)
# verify valid type triggers evmenu
self.call(cmd(), "", cmdstring="manage bugs")
evmenu_mock.assert_called_once_with(
self.account,
menu,
startnode="menunode_list_reports",
hub=hub,
persistent=True,
)