Repository: python-discord/bot

Issue:
Display time delta for infraction expiration
# Abstract

In addition to showing the expiration datetime in infraction searches, display the time delta between the expiration and creation of the infraction.

This is optional, but it would also be useful knowing how much time is left until the infraction expires. I may create a separate issue for this, and if so, link it here. EDIT - #669

EDIT2 - I would also like to see this time delta in the bot message confirmation after an infraction has been given out.
Example:
:incoming_envelope: :ok_hand: applied mute to @lemon until 2019-12-01 21:20 (7 days; 42 infractions total).

That last thing though is not mandatory for this issue to be closed. But I think it would be nice, because you could confirm immediately whether your duration was parsed as you expected.

# Specification

Current implementation:
```
Created: 2019-11-23 20:09
Expires: 2019-11-30 20:15
```
Suggested implementation:
```
Created: 2019-11-23 20:09
Expires: 2019-11-30 20:15 (7 days, 6 minutes)
```

The actual granularity of this can vary as durations get longer. For example, if a duration is longer than 30 days, we could put `(1 month)` rather than the exact amount of days. Same goes for years.

If the exact implementation of this doesn't warrant discussion, the one who implements this feature should use their best judgement on when to use the following units of time: (years, months, days, hours, minutes).

For fun, we could put `(∞)` next to permanent infractions.

# Rationale

Confirming the infraction duration should take little effort. If the infraction spills over multiple months, days, or hours for example, it requires more than a second of thinking to determine if the duration is right.
Examples:
```
Created: 2019-11-23 20:58
Expires: 2019-11-23 21:03  <-- 5 minutes, not 1 hour
```
```
Created: 2019-11-23 23:59
Expires: 2019-11-24 00:00 <-- 1 minute, not 1 day or 1 hour
```

# Motivation

I'm lazy.


Discussion:
This sounds interesting, I would like to work on this.

Should we make this a helper function to return the difference between any two given datetime objects in that format? That way we can choose where and when we apply this.
Here's a quick example of what I thought of:
```py
TIME_MARKS = (
    (60, 'second'),  # 1 minute
    (60, 'minute'),  # 1 hour
    (24, 'hour'),  # 1 day
    (7, 'day'),  # 1 week
    (4, 'week'),  # 1 month
    (12, 'month'),  # 1 year
    (999, 'year')  # dumb the rest as year, max 999
)

def get_duration(date_from: datetime, date_to: datetime) -> str:
    div = abs(date_from - date_to).total_seconds()
    results = []
    for unit, name in TIME_MARKS:
        div, amount = divmod(div, unit)
        if amount > 0:
            plural = 's' if amount > 1 else ''
            results.append(f"{amount:.0f} {name}{plural}")
    return ', '.join(results[::-1][:2])
```
When ran against these
```py
for a, b in (
    (datetime(2019, 12, 12, 0, 1), datetime(2019, 12, 12, 12, 0, 5)),
    (datetime(2019, 12, 12), datetime(2019, 12, 11, 23, 59)),
    (datetime(2019, 11, 23, 20, 9), datetime(2019, 11, 30, 20, 15)),
    (datetime(2019, 11, 23, 20, 9), datetime(2019, 4, 25, 20, 15)),
    (datetime(2019, 11, 23, 20, 58), datetime(2019, 11, 23, 21, 3)),
    (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 24, 0, 0)),
    (datetime(2019, 11, 23, 23, 59), datetime(2022, 11, 23, 23, 0)),
    (datetime(2019, 11, 23, 23, 59), datetime(2019, 11, 23, 23, 49, 5)),
):
    print(a, b, '->', get_duration(a, b))
```
Will give these as results:
```py
2019-12-12 00:01:00 2019-12-12 12:00:05 -> 11 hours, 59 minutes
2019-12-12 00:00:00 2019-12-11 23:59:00 -> 1 minute
2019-11-23 20:09:00 2019-11-30 20:15:00 -> 1 week, 6 minutes
2019-11-23 20:09:00 2019-04-25 20:15:00 -> 7 months, 2 weeks
2019-11-23 20:58:00 2019-11-23 21:03:00 -> 5 minutes
2019-11-23 23:59:00 2019-11-24 00:00:00 -> 1 minute
2019-11-23 23:59:00 2022-11-23 23:00:00 -> 3 years, 3 months
2019-11-23 23:59:00 2019-11-23 23:49:05 -> 9 minutes, 55 seconds
```
And then, we can be lazy together!

#### Edit:
Honorably mentioning Ava's method of adding `'s'`:
```py
results.append(f"{int(amount)} {name}{'s' * (amount > 1)}")
```
I really like that approach, can we use it?
@ikuyarihS this looks promising, and I agree a helper function is the right direction to go in. Let me know if you have any questions along the way.

I've assigned you this issue, and deferred #669 until this issue is fleshed out by you. I look forward to reviewing your progress.

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
@@ -2,6 +2,7 @@
 import logging
 import textwrap
 import typing as t
+from datetime import datetime
 
 import discord
 from discord.ext import commands
@@ -96,7 +97,7 @@ async def infraction_edit(
             confirm_messages.append("marked as permanent")
         elif duration is not None:
             request_data['expires_at'] = duration.isoformat()
-            expiry = duration.strftime(time.INFRACTION_FORMAT)
+            expiry = time.format_infraction_with_duration(request_data['expires_at'])
             confirm_messages.append(f"set to expire on {expiry}")
         else:
             confirm_messages.append("expiry unchanged")
@@ -234,7 +235,8 @@ def infraction_to_string(self, infraction: utils.Infraction) -> str:
         if infraction["expires_at"] is None:
             expires = "*Permanent*"
         else:
-            expires = time.format_infraction(infraction["expires_at"])
+            date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
+            expires = time.format_infraction_with_duration(infraction["expires_at"], date_from)
 
         lines = textwrap.dedent(f"""
             {"**===============**" if active else "==============="}
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -83,14 +83,11 @@ async def apply_infraction(
         infr_type = infraction["type"]
         icon = utils.INFRACTION_ICONS[infr_type][0]
         reason = infraction["reason"]
-        expiry = infraction["expires_at"]
+        expiry = time.format_infraction_with_duration(infraction["expires_at"])
         id_ = infraction['id']
 
         log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")
 
-        if expiry:
-            expiry = time.format_infraction(expiry)
-
         # Default values for the confirmation message and mod log.
         confirm_msg = f":ok_hand: applied"
 
diff --git a/bot/utils/time.py b/bot/utils/time.py
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -111,3 +111,26 @@ async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime]
 def format_infraction(timestamp: str) -> str:
     """Format an infraction timestamp to a more readable ISO 8601 format."""
     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:
+    """
+    Format an infraction timestamp to a more readable ISO 8601 format WITH the duration.
+
+    Returns a human-readable version of the duration between datetime.utcnow() and an expiry.
+    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
+
+    date_from = date_from or datetime.datetime.utcnow()
+    date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
+
+    expiry_formatted = format_infraction(expiry)
+
+    duration = humanize_delta(relativedelta(date_to, date_from), max_units=max_units)
+    duration_formatted = f" ({duration})" if duration else ''
+
+    return f"{expiry_formatted}{duration_formatted}"


Test patch: