From 69862742223c75276766ad7af5d87c17ac533342 Mon Sep 17 00:00:00 2001 From: Cal Date: Sat, 4 May 2024 17:49:46 -0600 Subject: [PATCH] reports contrib --- .../base_systems/ingame_reports/README.md | 123 +++++++++ .../base_systems/ingame_reports/__init__.py | 1 + .../base_systems/ingame_reports/menu.py | 134 ++++++++++ .../base_systems/ingame_reports/reports.py | 251 ++++++++++++++++++ .../base_systems/ingame_reports/tests.py | 1 + 5 files changed, 510 insertions(+) create mode 100644 evennia/contrib/base_systems/ingame_reports/README.md create mode 100644 evennia/contrib/base_systems/ingame_reports/__init__.py create mode 100644 evennia/contrib/base_systems/ingame_reports/menu.py create mode 100644 evennia/contrib/base_systems/ingame_reports/reports.py create mode 100644 evennia/contrib/base_systems/ingame_reports/tests.py diff --git a/evennia/contrib/base_systems/ingame_reports/README.md b/evennia/contrib/base_systems/ingame_reports/README.md new file mode 100644 index 000000000..70f876e58 --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/README.md @@ -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 + + 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. \ No newline at end of file diff --git a/evennia/contrib/base_systems/ingame_reports/__init__.py b/evennia/contrib/base_systems/ingame_reports/__init__.py new file mode 100644 index 000000000..d4ad3e32a --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/__init__.py @@ -0,0 +1 @@ +from .reports import ReportsCmdSet diff --git a/evennia/contrib/base_systems/ingame_reports/menu.py b/evennia/contrib/base_systems/ingame_reports/menu.py new file mode 100644 index 000000000..a7a4c98a2 --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/menu.py @@ -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 diff --git a/evennia/contrib/base_systems/ingame_reports/reports.py b/evennia/contrib/base_systems/ingame_reports/reports.py new file mode 100644 index 000000000..2cbc0decf --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/reports.py @@ -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 + + 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) diff --git a/evennia/contrib/base_systems/ingame_reports/tests.py b/evennia/contrib/base_systems/ingame_reports/tests.py new file mode 100644 index 000000000..777ab4dd1 --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/tests.py @@ -0,0 +1 @@ +# TODO: write tests