Repository: python-discord/bot

Issue:
Display time left until expiration of infraction
# Abstract

In addition to the creation and expiration datetimes displayed in an infraction search, display how much time is currently left until the infraction expires at the time of invocation.

# Specification

Proposal:
```
Created: 2019-11-23 20:09
Expires: 2019-11-30 20:15
Remaining: 5 days, 1 hour
```

In regards to granularity, my proposal is to only show the 2 largest units. So if it expires in more than a year, show only years and months. If over a month, show only months and days. If over a day, show days and hours. If over an hour, show hours and minutes. And finally, if over a minute, show only minutes, don't show seconds.

If it expires in less than a minute, either display "less than a minute" or just "1 minute" if that's too verbose.

If the infraction has already expired, show "Expired".
If the infraction never expires, perhaps this field should be omitted entirely.

# Rationale

Similar to #668, confirming how long until an infraction expires should require as little effort as possible.

# Motivation

See my motivation in #668.


Discussion:
Putting a pause on this issue until #668 is fleshed out.
Since #668 is done, can I work on this?
Sure thing. I'll assign you.

Resolving patch:
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -244,6 +244,12 @@ def infraction_to_string(self, infraction: utils.Infraction) -> str:
         user_id = infraction["user"]
         hidden = infraction["hidden"]
         created = time.format_infraction(infraction["inserted_at"])
+
+        if active:
+            remaining = time.until_expiration(infraction["expires_at"]) or "Expired"
+        else:
+            remaining = "Inactive"
+
         if infraction["expires_at"] is None:
             expires = "*Permanent*"
         else:
@@ -259,6 +265,7 @@ def infraction_to_string(self, infraction: utils.Infraction) -> str:
             Reason: {infraction["reason"] or "*None*"}
             Created: {created}
             Expires: {expires}
+            Remaining: {remaining}
             Actor: {actor.mention if actor else actor_id}
             ID: `{infraction["id"]}`
             {"**===============**" if active else "==============="}
diff --git a/bot/utils/time.py b/bot/utils/time.py
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -113,7 +113,11 @@ def format_infraction(timestamp: str) -> str:
     return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT)
 
 
-def format_infraction_with_duration(expiry: str, date_from: datetime.datetime = None, max_units: int = 2) -> str:
+def format_infraction_with_duration(
+    expiry: Optional[str],
+    date_from: datetime.datetime = None,
+    max_units: int = 2
+) -> Optional[str]:
     """
     Format an infraction timestamp to a more readable ISO 8601 format WITH the duration.
 
@@ -134,3 +138,23 @@ def format_infraction_with_duration(expiry: str, date_from: datetime.datetime =
     duration_formatted = f" ({duration})" if duration else ''
 
     return f"{expiry_formatted}{duration_formatted}"
+
+
+def until_expiration(expiry: Optional[str], max_units: int = 2) -> Optional[str]:
+    """
+    Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta.
+
+    Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it.
+    `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+    By default, max_units is 2.
+    """
+    if not expiry:
+        return None
+
+    now = datetime.datetime.utcnow()
+    since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
+
+    if since < now:
+        return None
+
+    return humanize_delta(relativedelta(since, now), max_units=max_units)


Test patch: