diff --git a/CLAUDE.md b/CLAUDE.md index 2269e22..e6d60cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,8 +101,10 @@ poetry run neongetter - `authorizations_or`: List of authorizations, any one sufficient to operate - `unauthorized_warn_only`: (optional) Allow operation but log warning for unauthorized users - `always_enabled`: (optional) Machine always enabled without RFID authentication, displays "Always On" + - `alias`: (optional) Human-friendly name used in Slack messages and logs instead of machine name - Users: `users.json` (schema in `models/users.py::CONFIG_SCHEMA`) - Machine names must match ESPHome configs and can only contain `[a-z0-9_-]` +- Machines can be looked up by either name or alias in Slack commands **State Persistence**: - Machine state is persisted to disk on every update using pickle diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 4a466fb..5280fd6 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -23,6 +23,8 @@ machines.json Machines are configured via a ``machines.json`` file in the current directory, or another file name/path specified in the ``MACHINES_CONFIG`` environment variable. This file lists all of the supported/configured machines and which authorization(s) are required to use them. Note that the names in this file must match the names configured in your ESPHome :ref:`hardware`. Machine names must be unqiue and can only contain alphanumeric characters, underscores, and dashes. No spaces, no dots. +Each machine configuration supports an optional ``alias`` field, which provides a human-friendly name for the machine. When present, the alias will be used in Slack messages and log output instead of the machine name. Both the machine name and alias can be used in incoming Slack commands. + The schema of this file is as follows: .. jsonschema:: dm_mac.models.machine.CONFIG_SCHEMA diff --git a/docs/source/slack.rst b/docs/source/slack.rst index 8d2dc06..5cde0c1 100644 --- a/docs/source/slack.rst +++ b/docs/source/slack.rst @@ -48,8 +48,10 @@ The slack bot is controlled by mentioning its name (``@your-bot-name``) along wi Using an example bot name of ``@machine-access-control``, the supported commands are: * ``@machine-access-control status`` - List all machines and their current status. This command is the only one that is usable from channels other than the control channel. -* ``@machine-access-control oops `` - Set Oops'ed status on the machine with name ``machine-name``. This takes effect immediately, even if the machine is currently in use. -* ``@machine-access-control lock `` - Set maintenance lock-out status on the machine with name ``machine-name``. This takes effect immediately, even if the machine is currently in use. -* ``@machine-access-control clear `` - Clear all Oops and/or maintenance lock-out states on the machine with name ``machine-name``. +* ``@machine-access-control oops `` - Set Oops'ed status on the machine with name ``machine-name``. This takes effect immediately, even if the machine is currently in use. You can use either the machine name or its alias (if configured). +* ``@machine-access-control lock `` - Set maintenance lock-out status on the machine with name ``machine-name``. This takes effect immediately, even if the machine is currently in use. You can use either the machine name or its alias (if configured). +* ``@machine-access-control clear `` - Clear all Oops and/or maintenance lock-out states on the machine with name ``machine-name``. You can use either the machine name or its alias (if configured). + +**Note:** If a machine has an ``alias`` configured in ``machines.json``, the bot's responses will use the alias instead of the machine name for better readability. In addition, changes to all machines' Oops and maintenance lock-out states will be posted as messages in the ``SLACK_OOPS_CHANNEL_ID`` channel. diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index dfa3430..b86d219 100644 --- a/src/dm_mac/models/machine.py +++ b/src/dm_mac/models/machine.py @@ -60,6 +60,11 @@ "Displays 'Always On' and relay is always " "on unless Oopsed or Locked.", }, + "alias": { + "type": "string", + "description": "Optional human-friendly alias for the machine. " + "Used in Slack messages and logs instead of the machine name.", + }, }, "additionalProperties": False, "description": "Unique machine name, alphanumeric _ and - only.", @@ -77,6 +82,7 @@ def __init__( authorizations_or: List[str], unauthorized_warn_only: bool = False, always_enabled: bool = False, + alias: Optional[str] = None, ): """Initialize a new MachineState instance.""" #: The name of the machine @@ -88,6 +94,8 @@ def __init__( self.unauthorized_warn_only: bool = unauthorized_warn_only #: Whether machine is always enabled without RFID authentication self.always_enabled: bool = always_enabled + #: Optional human-friendly alias for the machine + self.alias: Optional[str] = alias #: state of the machine self.state: "MachineState" = MachineState(self) @@ -145,6 +153,11 @@ async def unoops(self, slack: Optional["SlackHandler"] = None) -> None: return await slack.log_unoops(self, source) + @property + def display_name(self) -> str: + """Return the display name for this machine (alias if present, else name).""" + return self.alias if self.alias else self.name + @property def as_dict(self) -> Dict[str, Any]: """Return a dict representation of this machine.""" @@ -153,6 +166,7 @@ def as_dict(self) -> Dict[str, Any]: "authorizations_or": self.authorizations_or, "unauthorized_warn_only": self.unauthorized_warn_only, "always_enabled": self.always_enabled, + "alias": self.alias, } @@ -163,6 +177,7 @@ def __init__(self) -> None: """Initialize MachinesConfig.""" logger.debug("Initializing MachinesConfig") self.machines_by_name: Dict[str, Machine] = {} + self.machines_by_alias: Dict[str, Machine] = {} self.machines: List[Machine] = [] mdict: Dict[str, Any] mname: str @@ -170,8 +185,16 @@ def __init__(self) -> None: mach: Machine = Machine(name=mname, **mdict) self.machines.append(mach) self.machines_by_name[mach.name] = mach + if mach.alias: + self.machines_by_alias[mach.alias] = mach self.load_time: float = time() + def get_machine(self, name_or_alias: str) -> Optional[Machine]: + """Get a machine by name or alias.""" + return self.machines_by_name.get(name_or_alias) or self.machines_by_alias.get( + name_or_alias + ) + def _load_and_validate_config(self) -> Dict[str, Dict[str, Any]]: """Load and validate the config file.""" config: Dict[str, Dict[str, Any]] = cast( @@ -307,7 +330,8 @@ async def _handle_reboot(self) -> None: For always-enabled machines, restores the always-on state. """ logging.getLogger("AUTH").warning( - "Machine %s rebooted; resetting relay and RFID state", self.machine.name + "Machine %s rebooted; resetting relay and RFID state", + self.machine.display_name, ) # locking handled in update() self.current_user = None @@ -327,12 +351,12 @@ async def _handle_reboot(self) -> None: if not slack: # Slack integration is not enabled return - await slack.admin_log(f"Machine {self.machine.name} has rebooted.") + await slack.admin_log(f"Machine {self.machine.display_name} has rebooted.") def lockout(self) -> None: """Lock-out the machine.""" logging.getLogger("OOPS").warning( - "Machine %s was locked out for maintenance.", self.machine.name + "Machine %s was locked out for maintenance.", self.machine.display_name ) with self._lock: self.is_locked_out = True @@ -345,7 +369,8 @@ def lockout(self) -> None: def unlock(self) -> None: """Un-lock-out the machine.""" logging.getLogger("OOPS").warning( - "Machine %s was removed from maintenance lock-out.", self.machine.name + "Machine %s was removed from maintenance lock-out.", + self.machine.display_name, ) with self._lock: self.is_locked_out = False @@ -364,7 +389,9 @@ def unlock(self) -> None: def oops(self, do_locking: bool = True) -> None: """Oops the machine.""" - logging.getLogger("OOPS").warning("Machine %s was Oopsed.", self.machine.name) + logging.getLogger("OOPS").warning( + "Machine %s was Oopsed.", self.machine.display_name + ) locker = self._lock if do_locking else nullcontext() with locker: self.is_oopsed = True @@ -377,7 +404,7 @@ def oops(self, do_locking: bool = True) -> None: def unoops(self, do_locking: bool = True) -> None: """Un-oops the machine.""" logging.getLogger("OOPS").warning( - "Machine %s was un-Oopsed.", self.machine.name + "Machine %s was un-Oopsed.", self.machine.display_name ) locker = self._lock if do_locking else nullcontext() with locker: @@ -465,7 +492,7 @@ async def _handle_oops(self, users: UsersConfig) -> None: ustr = f" Current user is: {user.full_name}." uname = user.full_name logging.getLogger("OOPS").warning( - "Machine %s was Oopsed.%s", self.machine.name, ustr + "Machine %s was Oopsed.%s", self.machine.display_name, ustr ) # locking handled in update() self.oops(do_locking=False) @@ -485,12 +512,12 @@ async def _handle_rfid_remove(self) -> None: """Handle RFID card removed.""" logging.getLogger("AUTH").info( "RFID logout on %s by %s; session duration %d seconds", - self.machine.name, + self.machine.display_name, self.current_user.full_name if self.current_user else self.rfid_value, time() - cast(float, self.rfid_present_since), ) log_str: str = ( - f"RFID logout on {self.machine.name} by " + f"RFID logout on {self.machine.display_name} by " + (self.current_user.full_name if self.current_user else "unknown") + "; session duration " + naturaldelta(time() - cast(float, self.rfid_present_since)) @@ -521,13 +548,13 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None if not user: logging.getLogger("AUTH").warning( "RFID login attempt on %s by unknown fob %s", - self.machine.name, + self.machine.display_name, rfid_value, ) if self.is_oopsed or self.is_locked_out: if slack: await slack.admin_log( - f"RFID login attempt on {self.machine.name} " + f"RFID login attempt on {self.machine.display_name} " "by unknown fob when oopsed or locked out." ) return @@ -536,7 +563,7 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None self.status_led_brightness = self.STATUS_LED_BRIGHTNESS if slack: await slack.admin_log( - f"RFID login attempt on {self.machine.name} by unknown fob" + f"RFID login attempt on {self.machine.display_name} by unknown fob" ) return # ok, we have a known user @@ -544,26 +571,26 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None if self.is_oopsed: logging.getLogger("AUTH").warning( "RFID login attempt while oopsed on %s by %s", - self.machine.name, + self.machine.display_name, logname, ) # don't change anything if slack: await slack.admin_log( - f"RFID login attempt on {self.machine.name} by " + f"RFID login attempt on {self.machine.display_name} by " f"{user.full_name} when oopsed." ) return if self.is_locked_out: logging.getLogger("AUTH").warning( "RFID login attempt while locked out on %s by %s", - self.machine.name, + self.machine.display_name, logname, ) # don't change anything if slack: await slack.admin_log( - f"RFID login attempt on {self.machine.name} by " + f"RFID login attempt on {self.machine.display_name} by " f"{user.full_name} when machine locked-out." ) return @@ -572,7 +599,7 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None "User %s (%s) authorized for %s; session start", user.full_name, user.account_id, - self.machine.name, + self.machine.display_name, ) self.current_user = user self.relay_desired_state = True @@ -581,7 +608,7 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None self.status_led_brightness = self.STATUS_LED_BRIGHTNESS if slack: await slack.admin_log( - f"RFID login on {self.machine.name} by authorized user " + f"RFID login on {self.machine.display_name} by authorized user " f"{user.full_name}" ) else: @@ -589,7 +616,7 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None "User %s (%s) UNAUTHORIZED for %s", user.full_name, user.account_id, - self.machine.name, + self.machine.display_name, ) self.relay_desired_state = False self.display_text = "Unauthorized" @@ -597,7 +624,7 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None self.status_led_brightness = self.STATUS_LED_BRIGHTNESS if slack: await slack.admin_log( - f"rejected RFID login on {self.machine.name} by " + f"rejected RFID login on {self.machine.display_name} by " f"UNAUTHORIZED user {user.full_name}" ) @@ -614,7 +641,7 @@ async def _handle_rfid_tracking_always_enabled( # RFID removed logging.getLogger("AUTH").info( "RFID removed on always-enabled machine %s (was %s); session duration %d seconds", - self.machine.name, + self.machine.display_name, self.current_user.full_name if self.current_user else self.rfid_value, ( time() - cast(float, self.rfid_present_since) @@ -635,14 +662,14 @@ async def _handle_rfid_tracking_always_enabled( self.current_user = user logging.getLogger("AUTH").info( "RFID inserted on always-enabled machine %s by %s (%s)", - self.machine.name, + self.machine.display_name, user.full_name, rfid_value, ) else: logging.getLogger("AUTH").warning( "RFID inserted on always-enabled machine %s by unknown fob %s", - self.machine.name, + self.machine.display_name, rfid_value, ) # State remains always-on (relay/display/LED not changed) @@ -657,7 +684,7 @@ async def _user_is_authorized( "User %s (%s) authorized for %s based on %s", user.full_name, user.account_id, - self.machine.name, + self.machine.display_name, auth, ) return True @@ -667,12 +694,12 @@ async def _user_is_authorized( "unauthorized_warn_only==True", user.full_name, user.account_id, - self.machine.name, + self.machine.display_name, ) if slack: await slack.admin_log( f"WARNING - Authorizing user {user.full_name} for " - f"{self.machine.name} based on unauthorized_warn_only " + f"{self.machine.display_name} based on unauthorized_warn_only " "setting for machine. User is NOT authorized for this " "machine." ) diff --git a/src/dm_mac/slack_handler.py b/src/dm_mac/slack_handler.py index 1b98cca..82738ff 100644 --- a/src/dm_mac/slack_handler.py +++ b/src/dm_mac/slack_handler.py @@ -160,11 +160,11 @@ async def handle_command(self, msg: Message, say: AsyncSay) -> None: msg.channel_id, ) return None - if msg.command[0] == "oops" and len(msg.command) == 2: + if msg.command[0] == "oops" and len(msg.command) >= 2: return await self.oops(msg, say) - elif msg.command[0] == "lock" and len(msg.command) == 2: + elif msg.command[0] == "lock" and len(msg.command) >= 2: return await self.lock(msg, say) - elif msg.command[0] == "clear" and len(msg.command) == 2: + elif msg.command[0] == "clear" and len(msg.command) >= 2: return await self.clear(msg, say) await say(self.HELP_RESPONSE) @@ -175,7 +175,7 @@ async def machine_status(self, say: AsyncSay) -> None: mname: str mach: Machine for mname, mach in sorted(mconf.machines_by_name.items()): - resp += mname + ": " + resp += mach.display_name + ": " if mach.state.is_oopsed or mach.state.is_locked_out: if mach.state.is_oopsed: resp += "Oopsed " @@ -199,44 +199,44 @@ async def machine_status(self, say: AsyncSay) -> None: async def oops(self, msg: Message, say: AsyncSay) -> None: """Set oops status on a machine.""" - mname: str = msg.command[1] + mname: str = " ".join(msg.command[1:]) mconf: MachinesConfig = self.quart.config["MACHINES"] - mach: Optional[Machine] = mconf.machines_by_name.get(mname) + mach: Optional[Machine] = mconf.get_machine(mname) if not mach: await say( - f"Invalid machine name '{mname}'. Use status command to " + f"Invalid machine name or alias '{mname}'. Use status command to " f"list all machines." ) return if mach.state.is_oopsed: - await say(f"Machine {mname} is already oopsed.") + await say(f"Machine {mach.display_name} is already oopsed.") return await mach.oops(slack=self) async def lock(self, msg: Message, say: AsyncSay) -> None: """Set lock status on a machine.""" - mname: str = msg.command[1] + mname: str = " ".join(msg.command[1:]) mconf: MachinesConfig = self.quart.config["MACHINES"] - mach: Optional[Machine] = mconf.machines_by_name.get(mname) + mach: Optional[Machine] = mconf.get_machine(mname) if not mach: await say( - f"Invalid machine name '{mname}'. Use status command to " + f"Invalid machine name or alias '{mname}'. Use status command to " f"list all machines." ) return if mach.state.is_locked_out: - await say(f"Machine {mname} is already locked-out.") + await say(f"Machine {mach.display_name} is already locked-out.") return await mach.lockout(slack=self) async def clear(self, msg: Message, say: AsyncSay) -> None: """Clear oops and lock status on a machine.""" - mname: str = msg.command[1] + mname: str = " ".join(msg.command[1:]) mconf: MachinesConfig = self.quart.config["MACHINES"] - mach: Optional[Machine] = mconf.machines_by_name.get(mname) + mach: Optional[Machine] = mconf.get_machine(mname) if not mach: await say( - f"Invalid machine name '{mname}'. Use status command to " + f"Invalid machine name or alias '{mname}'. Use status command to " f"list all machines." ) return @@ -248,7 +248,7 @@ async def clear(self, msg: Message, say: AsyncSay) -> None: await mach.unlock(slack=self) acted = True if not acted: - await say(f"Machine {mname} is not oopsed or locked-out.") + await say(f"Machine {mach.display_name} is not oopsed or locked-out.") async def log_unoops(self, machine: Machine, source: str) -> None: """ @@ -262,13 +262,13 @@ async def log_unoops(self, machine: Machine, source: str) -> None: create_task( self.app.client.chat_postMessage( channel=self.control_channel_id, - text=f"Machine {machine.name} un-oopsed via {source}.", + text=f"Machine {machine.display_name} un-oopsed via {source}.", ) ) create_task( self.app.client.chat_postMessage( channel=self.oops_channel_id, - text=f"Machine {machine.name} oops has been cleared.", + text=f"Machine {machine.display_name} oops has been cleared.", ) ) @@ -286,13 +286,13 @@ async def log_oops( create_task( self.app.client.chat_postMessage( channel=self.control_channel_id, - text=f"Machine {machine.name} oopsed via {source} by {user_name}.", + text=f"Machine {machine.display_name} oopsed via {source} by {user_name}.", ) ) create_task( self.app.client.chat_postMessage( channel=self.oops_channel_id, - text=f"Machine {machine.name} has been Oops'ed!", + text=f"Machine {machine.display_name} has been Oops'ed!", ) ) @@ -308,13 +308,13 @@ async def log_unlock(self, machine: Machine, source: str) -> None: create_task( self.app.client.chat_postMessage( channel=self.control_channel_id, - text=f"Machine {machine.name} locked-out cleared via {source}.", + text=f"Machine {machine.display_name} locked-out cleared via {source}.", ) ) create_task( self.app.client.chat_postMessage( channel=self.oops_channel_id, - text=f"Machine {machine.name} is no longer locked-out for " + text=f"Machine {machine.display_name} is no longer locked-out for " f"maintenance.", ) ) @@ -331,13 +331,13 @@ async def log_lock(self, machine: Machine, source: str) -> None: create_task( self.app.client.chat_postMessage( channel=self.control_channel_id, - text=f"Machine {machine.name} locked-out via {source}.", + text=f"Machine {machine.display_name} locked-out via {source}.", ) ) create_task( self.app.client.chat_postMessage( channel=self.oops_channel_id, - text=f"Machine {machine.name} is locked-out for maintenance.", + text=f"Machine {machine.display_name} is locked-out for maintenance.", ) ) diff --git a/tests/fixtures/machines.json b/tests/fixtures/machines.json index 1149cae..555cc66 100644 --- a/tests/fixtures/machines.json +++ b/tests/fixtures/machines.json @@ -1,6 +1,7 @@ { "metal-mill": { - "authorizations_or": ["Metal Mill"] + "authorizations_or": ["Metal Mill"], + "alias": "Metal Mill" }, "hammer": { "authorizations_or": ["Woodshop Orientation", "Woodshop 201", "Woodshop 101"], diff --git a/tests/models/test_machine.py b/tests/models/test_machine.py index b410cfd..85be2f4 100644 --- a/tests/models/test_machine.py +++ b/tests/models/test_machine.py @@ -71,6 +71,7 @@ def test_config_path(self, fixtures_path: str, tmp_path: Path) -> None: "authorizations_or": ["Metal Mill"], "unauthorized_warn_only": False, "always_enabled": False, + "alias": None, } assert cls.load_time == 1689477248.0 @@ -119,6 +120,7 @@ def test_happy_path(self) -> None: "authorizations_or": ["Foo", "Bar"], "unauthorized_warn_only": False, "always_enabled": False, + "alias": None, } def test_unauth_warn(self) -> None: @@ -139,4 +141,98 @@ def test_unauth_warn(self) -> None: "authorizations_or": ["Foo", "Bar"], "unauthorized_warn_only": True, "always_enabled": False, + "alias": None, } + + def test_with_alias(self) -> None: + """Test machine with alias.""" + with patch(f"{pbm}.MachineState", autospec=True) as m_state: + cls: Machine = Machine( + name="mName", + authorizations_or=["Foo", "Bar"], + alias="My Machine", + ) + assert cls.name == "mName" + assert cls.alias == "My Machine" + assert cls.display_name == "My Machine" + assert cls.authorizations_or == ["Foo", "Bar"] + assert m_state.mock_calls == [call(cls)] + assert cls.state == m_state.return_value + assert cls.as_dict == { + "name": "mName", + "authorizations_or": ["Foo", "Bar"], + "unauthorized_warn_only": False, + "always_enabled": False, + "alias": "My Machine", + } + + def test_display_name_without_alias(self) -> None: + """Test display_name property when no alias is set.""" + with patch(f"{pbm}.MachineState", autospec=True): + cls: Machine = Machine( + name="mName", + authorizations_or=["Foo", "Bar"], + ) + assert cls.display_name == "mName" + + def test_display_name_with_alias(self) -> None: + """Test display_name property when alias is set.""" + with patch(f"{pbm}.MachineState", autospec=True): + cls: Machine = Machine( + name="mName", + authorizations_or=["Foo", "Bar"], + alias="My Machine", + ) + assert cls.display_name == "My Machine" + + +class TestMachinesConfigGetMachine: + """Tests for MachinesConfig.get_machine method.""" + + def test_get_machine_by_name(self, fixtures_path: str, tmp_path: Path) -> None: + """Test getting a machine by name.""" + conf: Dict[str, Dict[str, Any]] = { + "metal-mill": {"authorizations_or": ["Metal Mill"], "alias": "Metal Mill"}, + "hammer": {"authorizations_or": ["Woodshop Orientation"]}, + } + cpath: str = str(os.path.join(tmp_path, "machines.json")) + with open(cpath, "w") as fh: + json.dump(conf, fh, sort_keys=True, indent=4) + with patch.dict(os.environ, {"MACHINES_CONFIG": cpath}): + with patch(f"{pbm}.MachineState", autospec=True): + cls: MachinesConfig = MachinesConfig() + machine = cls.get_machine("metal-mill") + assert machine is not None + assert machine.name == "metal-mill" + assert machine.alias == "Metal Mill" + + def test_get_machine_by_alias(self, fixtures_path: str, tmp_path: Path) -> None: + """Test getting a machine by alias.""" + conf: Dict[str, Dict[str, Any]] = { + "metal-mill": {"authorizations_or": ["Metal Mill"], "alias": "Metal Mill"}, + "hammer": {"authorizations_or": ["Woodshop Orientation"]}, + } + cpath: str = str(os.path.join(tmp_path, "machines.json")) + with open(cpath, "w") as fh: + json.dump(conf, fh, sort_keys=True, indent=4) + with patch.dict(os.environ, {"MACHINES_CONFIG": cpath}): + with patch(f"{pbm}.MachineState", autospec=True): + cls: MachinesConfig = MachinesConfig() + machine = cls.get_machine("Metal Mill") + assert machine is not None + assert machine.name == "metal-mill" + assert machine.alias == "Metal Mill" + + def test_get_machine_not_found(self, fixtures_path: str, tmp_path: Path) -> None: + """Test getting a machine that doesn't exist.""" + conf: Dict[str, Dict[str, Any]] = { + "metal-mill": {"authorizations_or": ["Metal Mill"], "alias": "Metal Mill"}, + } + cpath: str = str(os.path.join(tmp_path, "machines.json")) + with open(cpath, "w") as fh: + json.dump(conf, fh, sort_keys=True, indent=4) + with patch.dict(os.environ, {"MACHINES_CONFIG": cpath}): + with patch(f"{pbm}.MachineState", autospec=True): + cls: MachinesConfig = MachinesConfig() + machine = cls.get_machine("nonexistent") + assert machine is None diff --git a/tests/test_slack_handler.py b/tests/test_slack_handler.py index bd019d5..584774e 100644 --- a/tests/test_slack_handler.py +++ b/tests/test_slack_handler.py @@ -267,7 +267,7 @@ async def test_handle_command_status_admin_channel(self, tmp_path) -> None: "esp32test: Idle \n" "hammer: Idle (last contact a minute ago; last update a minute ago;" " uptime 2 minutes)\n" - "metal-mill: Oopsed (last contact 10 seconds ago; last update a " + "Metal Mill: Oopsed (last contact 10 seconds ago; last update a " "minute ago; uptime a day)\n" "permissive-lathe: Locked out (last contact 6 days ago; " "last update 7 days ago; uptime 6 minutes)\n" @@ -328,7 +328,7 @@ async def test_handle_command_status_oops_channel(self, tmp_path) -> None: "esp32test: Idle \n" "hammer: Idle (last contact a minute ago; " "last update a minute ago; uptime 2 minutes)\n" - "metal-mill: Oopsed (last contact 10 seconds ago; " + "Metal Mill: Oopsed (last contact 10 seconds ago; " "last update a minute ago; uptime a day)\n" "permissive-lathe: Locked out (last contact 6 days ago; " "last update 7 days ago; uptime 6 minutes)\n" @@ -381,10 +381,10 @@ async def test_handle_command_oops(self, tmp_path) -> None: assert self.slack_client.mock_calls == [ call.chat_postMessage( channel="Cadmin", - text="Machine metal-mill oopsed via Slack by unknown user.", + text="Machine Metal Mill oopsed via Slack by unknown user.", ), call.chat_postMessage( - channel="Coops", text="Machine metal-mill has been Oops'ed!" + channel="Coops", text="Machine Metal Mill has been Oops'ed!" ), ] assert self.slack_app.mock_calls == [] @@ -409,7 +409,7 @@ async def test_handle_command_oops_already_oopsed(self, tmp_path) -> None: ) say = AsyncMock() await self.cls.handle_command(msg, say) - assert say.mock_calls == [call("Machine metal-mill is already oopsed.")] + assert say.mock_calls == [call("Machine Metal Mill is already oopsed.")] assert self.slack_client.mock_calls == [] assert self.slack_app.mock_calls == [] assert mconf.machines_by_name["metal-mill"].state.is_oopsed is True @@ -435,7 +435,7 @@ async def test_handle_command_oops_invalid_machine(self, tmp_path) -> None: await self.cls.handle_command(msg, say) assert say.mock_calls == [ call( - "Invalid machine name 'invalid-name'. " + "Invalid machine name or alias 'invalid-name'. " "Use status command to list all machines." ) ] @@ -464,11 +464,11 @@ async def test_handle_command_lock(self, tmp_path) -> None: assert say.mock_calls == [] assert self.slack_client.mock_calls == [ call.chat_postMessage( - channel="Cadmin", text="Machine metal-mill locked-out via Slack." + channel="Cadmin", text="Machine Metal Mill locked-out via Slack." ), call.chat_postMessage( channel="Coops", - text="Machine metal-mill is locked-out for maintenance.", + text="Machine Metal Mill is locked-out for maintenance.", ), ] assert self.slack_app.mock_calls == [] @@ -493,7 +493,7 @@ async def test_handle_command_lock_already_locked(self, tmp_path) -> None: ) say = AsyncMock() await self.cls.handle_command(msg, say) - assert say.mock_calls == [call("Machine metal-mill is already locked-out.")] + assert say.mock_calls == [call("Machine Metal Mill is already locked-out.")] assert self.slack_client.mock_calls == [] assert self.slack_app.mock_calls == [] assert mconf.machines_by_name["metal-mill"].state.is_locked_out is True @@ -519,7 +519,7 @@ async def test_handle_command_lock_invalid_machine(self, tmp_path) -> None: await self.cls.handle_command(msg, say) assert say.mock_calls == [ call( - "Invalid machine name 'invalid-name'. " + "Invalid machine name or alias 'invalid-name'. " "Use status command to list all machines." ) ] @@ -548,10 +548,10 @@ async def test_handle_command_clear_when_oops(self, tmp_path) -> None: assert say.mock_calls == [] assert self.slack_client.mock_calls == [ call.chat_postMessage( - channel="Cadmin", text="Machine metal-mill un-oopsed via Slack." + channel="Cadmin", text="Machine Metal Mill un-oopsed via Slack." ), call.chat_postMessage( - channel="Coops", text="Machine metal-mill oops has been cleared." + channel="Coops", text="Machine Metal Mill oops has been cleared." ), ] assert self.slack_app.mock_calls == [] @@ -581,11 +581,11 @@ async def test_handle_command_clear_when_locked(self, tmp_path) -> None: assert self.slack_client.mock_calls == [ call.chat_postMessage( channel="Cadmin", - text="Machine metal-mill locked-out cleared via Slack.", + text="Machine Metal Mill locked-out cleared via Slack.", ), call.chat_postMessage( channel="Coops", - text="Machine metal-mill is no longer locked-out for " "maintenance.", + text="Machine Metal Mill is no longer locked-out for " "maintenance.", ), ] assert self.slack_app.mock_calls == [] @@ -612,7 +612,7 @@ async def test_handle_command_clear_when_clear(self, tmp_path) -> None: say = AsyncMock() await self.cls.handle_command(msg, say) assert say.mock_calls == [ - call("Machine metal-mill is not oopsed or locked-out.") + call("Machine Metal Mill is not oopsed or locked-out.") ] assert self.slack_client.mock_calls == [] assert self.slack_app.mock_calls == [] @@ -640,7 +640,7 @@ async def test_handle_command_clear_invalid_machine(self, tmp_path) -> None: await self.cls.handle_command(msg, say) assert say.mock_calls == [ call( - "Invalid machine name 'invalid-name'. " + "Invalid machine name or alias 'invalid-name'. " "Use status command to list all machines." ) ] @@ -682,6 +682,38 @@ async def test_admin_log(self, tmp_path) -> None: ] assert self.slack_app.mock_calls == [] + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_oops_by_alias(self, tmp_path) -> None: + """Oops command using machine alias instead of name.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> oops Metal Mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [] + assert self.slack_client.mock_calls == [ + call.chat_postMessage( + channel="Cadmin", + text="Machine Metal Mill oopsed via Slack by unknown user.", + ), + call.chat_postMessage( + channel="Coops", text="Machine Metal Mill has been Oops'ed!" + ), + ] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_oopsed is True + def setup_machines(fixture_dir: Path, test_class: TestSlackHandler) -> None: fpath: str = os.path.abspath( diff --git a/tests/views/test_machine.py b/tests/views/test_machine.py index c967482..9fa11ab 100644 --- a/tests/views/test_machine.py +++ b/tests/views/test_machine.py @@ -1085,7 +1085,7 @@ async def test_rfid_authorized_inserted_slack(self, tmp_path: Path) -> None: assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS assert slack.mock_calls == [ call.admin_log( - "RFID login on metal-mill by authorized user Ashley Williams" + "RFID login on Metal Mill by authorized user Ashley Williams" ) ] @@ -1391,7 +1391,7 @@ async def test_rfid_unauthorized_inserted_zeropad_slack( assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS assert slack.mock_calls == [ call.admin_log( - "rejected RFID login on metal-mill by UNAUTHORIZED user " + "rejected RFID login on Metal Mill by UNAUTHORIZED user " "Kenneth Hunter" ) ] @@ -1570,7 +1570,7 @@ async def test_rfid_unknown_inserted_slack(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (1.0, 0.0, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS assert slack.mock_calls == [ - call.admin_log("RFID login attempt on metal-mill by unknown fob") + call.admin_log("RFID login attempt on Metal Mill by unknown fob") ] async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: @@ -2246,7 +2246,7 @@ async def test_rfid_authorized_inserted_slack(self, tmp_path: Path) -> None: assert ms.last_update == 1689477248.0 assert slack.mock_calls == [ call.admin_log( - "RFID login attempt on metal-mill by Ashley Williams " "when oopsed." + "RFID login attempt on Metal Mill by Ashley Williams " "when oopsed." ) ] @@ -2615,7 +2615,7 @@ async def test_rfid_unknown_inserted_slack(self, tmp_path: Path) -> None: assert ms.last_update == 1689477248.0 assert slack.mock_calls == [ call.admin_log( - "RFID login attempt on metal-mill by unknown fob when oopsed " + "RFID login attempt on Metal Mill by unknown fob when oopsed " "or locked out." ) ] @@ -2803,7 +2803,7 @@ async def test_rfid_authorized_inserted_slack(self, tmp_path: Path) -> None: assert ms.last_update == 1689477248.0 assert slack.mock_calls == [ call.admin_log( - "RFID login attempt on metal-mill by Ashley Williams " + "RFID login attempt on Metal Mill by Ashley Williams " "when machine locked-out." ) ]