Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework the Slack info command #405

Merged
merged 6 commits into from
Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 160 additions & 5 deletions api/slack/commands/info.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from api.serializers import VolunteerSerializer
from datetime import datetime, timedelta
from typing import Dict, Optional

from django.utils import timezone

from api.models import TranscriptionCheck
from api.slack import client
from api.slack.utils import dict_to_table, parse_user
from api.views.misc import Summary
from authentication.models import BlossomUser
from blossom.strings import translation

i18n = translation()
Expand All @@ -22,13 +28,162 @@ def info_cmd(channel: str, message: str) -> None:
elif len(parsed_message) == 2:
user, username = parse_user(parsed_message[1])
if user:
v_data = VolunteerSerializer(user).data
msg = i18n["slack"]["user_info"].format(
username, "\n".join(dict_to_table(v_data))
)
msg = user_info_text(user)
else:
msg = i18n["slack"]["errors"]["unknown_username"].format(username=username)
else:
msg = i18n["slack"]["errors"]["too_many_params"]

client.chat_postMessage(channel=channel, text=msg)


def user_info_text(user: BlossomUser) -> str:
"""Get the info message for the given user."""
name_link = f"<https://reddit.com/u/{user.username}|u/{user.username}>"
title = f"Info about *{name_link}*:"

general = _format_info_section("General", user_general_info(user))
transcription_quality = _format_info_section(
"Transcription Quality", user_transcription_quality_info(user)
)
debug = _format_info_section("Debug Info", user_debug_info(user))

return f"{title}\n\n{general}\n\n{transcription_quality}\n\n{debug}"


def _format_info_section(name: str, section: Dict) -> str:
"""Format a given info section to a readable string.

Example:
*Section name*:
- Key 1: Value 1
- Key 2: Value 2
"""
section_items = "\n".join([f"- {key}: {value}" for key, value in section.items()])

return f"*{name}*:\n{section_items}"


def user_general_info(user: BlossomUser) -> Dict:
"""Get general info for the given user."""
total_gamma = user.gamma
recent_gamma = user.gamma_at_time(start_time=timezone.now() - timedelta(weeks=2))
gamma = f"{total_gamma} Γ ({recent_gamma} Γ in last 2 weeks)"
joined_on = _format_time(user.date_joined)
last_active = _format_time(user.date_last_active()) or "Never"

return {
"Gamma": gamma,
"Joined on": joined_on,
"Last active": last_active,
}


def user_transcription_quality_info(user: BlossomUser) -> Dict:
"""Get info about the transcription quality of the given user."""
gamma = user.gamma
check_status = TranscriptionCheck.TranscriptionCheckStatus

# The checks for the given user
user_checks = TranscriptionCheck.objects.filter(transcription__author=user)
check_count = user_checks.count()
check_ratio = check_count / gamma if gamma > 0 else 0
checks = f"{check_count} ({check_ratio:.1%} of transcriptions)"

# The warnings for the given user
user_warnings_pending = user_checks.filter(status=check_status.WARNING_PENDING)
user_warnings_resolved = user_checks.filter(status=check_status.WARNING_RESOLVED)
user_warnings_unfixed = user_checks.filter(status=check_status.WARNING_UNFIXED)
warnings_count = (
user_warnings_pending.count()
+ user_warnings_resolved.count()
+ user_warnings_unfixed.count()
)
warnings_ratio = warnings_count / check_count if check_count > 0 else 0
warnings = f"{warnings_count} ({warnings_ratio:.1%} of checks)"

watch_status = user.transcription_check_reason(ignore_low_activity=True)

return {
"Checks": checks,
"Warnings": warnings,
"Watch status": watch_status,
}


def user_debug_info(user: BlossomUser) -> Dict:
"""Get debug info about the given user."""
user_id = f"`{user.id}`"
blacklisted = bool_str(user.blacklisted)
bot = bool_str(user.is_bot)
accepted_coc = bool_str(user.accepted_coc)

return {
"ID": user_id,
"Blacklisted": blacklisted,
"Bot": bot,
"Accepted CoC": accepted_coc,
}


def bool_str(bl: bool) -> str:
"""Convert a bool to a Yes/No string."""
return "Yes" if bl else "No"


def _format_time(time: Optional[datetime]) -> Optional[str]:
"""Format the given time in absolute and relative strings."""
if time is None:
return None

now = timezone.now()
absolute = time.date().isoformat()

relative_delta = now - time
relative = _relative_duration(relative_delta)

if now >= time:
return f"{absolute} ({relative} ago)"
else:
return f"{absolute} (in {relative})"


def _relative_duration(delta: timedelta) -> str:
"""Format the delta into a relative time string."""
seconds = abs(delta.total_seconds())
minutes = seconds / 60
hours = minutes / 60
days = hours / 24
weeks = days / 7
months = days / 30
years = days / 365

# Determine major time unit
if years >= 1:
value, unit = years, "year"
elif months >= 1:
value, unit = months, "month"
elif weeks >= 1:
value, unit = weeks, "week"
elif days >= 1:
value, unit = days, "day"
elif hours >= 1:
value, unit = hours, "hour"
elif minutes >= 1:
value, unit = minutes, "min"
elif seconds > 5:
value, unit = seconds, "sec"
else:
duration_ms = seconds / 1000
value, unit = duration_ms, "ms"

if unit == "ms":
duration_str = f"{value:0.0f} ms"
else:
# Add plural s if necessary
if value != 1:
unit += "s"

duration_str = f"{value:.1f} {unit}"

return duration_str
118 changes: 118 additions & 0 deletions api/tests/slack/commands/test_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from datetime import datetime, timedelta
from unittest.mock import patch

import pytz
from django.test import Client

from api.models import TranscriptionCheck
from api.slack.commands.info import user_info_text
from utils.test_helpers import (
create_check,
create_submission,
create_transcription,
setup_user_client,
)


def test_user_info_text_new_user(client: Client) -> None:
"""Verify that the string for a new user is generated correctly."""
client, headers, user = setup_user_client(
client,
id=123,
username="Userson",
date_joined=datetime(2021, 5, 21, tzinfo=pytz.UTC),
accepted_coc=False,
is_bot=False,
)

expected = """Info about *<https://reddit.com/u/Userson|u/Userson>*:

*General*:
- Gamma: 0 Γ (0 Γ in last 2 weeks)
- Joined on: 2021-05-21 (1.0 day ago)
- Last active: Never

*Transcription Quality*:
- Checks: 0 (0.0% of transcriptions)
- Warnings: 0 (0.0% of checks)
- Watch status: Automatic (100.0%)

*Debug Info*:
- ID: `123`
- Blacklisted: No
- Bot: No
- Accepted CoC: No"""

with patch(
"api.slack.commands.info.timezone.now",
return_value=datetime(2021, 5, 22, tzinfo=pytz.UTC),
):
actual = user_info_text(user)

assert actual == expected


def test_user_info_text_old_user(client: Client) -> None:
"""Verify that the string for a new user is generated correctly."""
client, headers, user = setup_user_client(
client,
id=123,
username="Userson",
date_joined=datetime(2020, 4, 21, tzinfo=pytz.UTC),
accepted_coc=True,
is_bot=False,
)

tr_data = [
(datetime(2020, 7, 3, tzinfo=pytz.UTC), True, False),
(datetime(2020, 7, 7, tzinfo=pytz.UTC), False, False),
(datetime(2020, 7, 9, tzinfo=pytz.UTC), False, False),
(datetime(2021, 4, 10, tzinfo=pytz.UTC), True, True),
(datetime(2021, 4, 12, tzinfo=pytz.UTC), False, False),
]

for idx, (date, has_check, is_warning) in enumerate(tr_data):
submission = create_submission(
id=100 + idx,
create_time=date - timedelta(days=1),
claim_time=date,
complete_time=date + timedelta(days=1),
claimed_by=user,
completed_by=user,
)
transcription = create_transcription(
submission, user, id=200 + idx, create_time=date
)

if has_check:
check_status = TranscriptionCheck.TranscriptionCheckStatus
status = (
check_status.WARNING_RESOLVED if is_warning else check_status.APPROVED
)
create_check(transcription, status=status)

expected = """Info about *<https://reddit.com/u/Userson|u/Userson>*:

*General*:
- Gamma: 5 Γ (2 Γ in last 2 weeks)
- Joined on: 2020-04-21 (1.0 year ago)
- Last active: 2021-04-13 (1.1 weeks ago)

*Transcription Quality*:
- Checks: 2 (40.0% of transcriptions)
- Warnings: 1 (50.0% of checks)
- Watch status: Automatic (100.0%)

*Debug Info*:
- ID: `123`
- Blacklisted: No
- Bot: No
- Accepted CoC: Yes"""

with patch(
"api.slack.commands.info.timezone.now",
return_value=datetime(2021, 4, 21, tzinfo=pytz.UTC),
):
actual = user_info_text(user)

assert actual == expected
31 changes: 28 additions & 3 deletions authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@

class BlossomUserManager(UserManager):
# https://stackoverflow.com/a/7774039
def filter(self, **kwargs: Any) -> QuerySet:
def filter(self, **kwargs: Any) -> QuerySet: # noqa: ANN401
"""Override `filter` to make usernames case insensitive."""
if "username" in kwargs:
kwargs["username__iexact"] = kwargs["username"]
del kwargs["username"]
return super().filter(**kwargs)

def get(self, **kwargs: Any) -> QuerySet:
def get(self, **kwargs: Any) -> QuerySet: # noqa: ANN401
"""Override `get` to make usernames case insensitive."""
if "username" in kwargs:
kwargs["username__iexact"] = kwargs["username"]
Expand Down Expand Up @@ -100,6 +100,30 @@ class Meta:

objects = BlossomUserManager()

def date_last_active(self) -> Optional[datetime]:
"""Return the time where the user was last active.

This will give the time where the user last claimed or completed a post.
"""
recently_claimed = (
Submission.objects.filter(claimed_by=self).order_by("-claim_time").first()
)
recent_claim_time = recently_claimed.claim_time if recently_claimed else None

recently_completed = (
Submission.objects.filter(completed_by=self)
.order_by("-complete_time")
.first()
)
recent_complete_time = (
recently_completed.complete_time if recently_completed else None
)

if recent_claim_time and recent_complete_time:
return max(recent_complete_time, recent_claim_time)

return recent_claim_time or recent_complete_time

@property
def gamma(self) -> int:
"""
Expand Down Expand Up @@ -184,7 +208,8 @@ def has_low_activity(self) -> bool:
"""
recent_date = datetime.now(tz=pytz.UTC) - LOW_ACTIVITY_TIMEDELTA
recent_transcriptions = Submission.objects.filter(
completed_by=self, complete_time__gte=recent_date,
completed_by=self,
complete_time__gte=recent_date,
).count()

return recent_transcriptions <= LOW_ACTIVITY_THRESHOLD
Expand Down
8 changes: 0 additions & 8 deletions blossom/strings/en_US.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,6 @@ Here's a status update on everything!
```
"""

user_info="""
User info for {0}:

```
{1}
```
"""

github_sponsor_update="{0} GitHub Sponsors: [{1}] - {2} | {3} {0}"

[slack.errors]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "blossom"
version = "1.71.0"
version = "1.72.0"
description = "The site!"
authors = ["Grafeas Group Ltd. <devs@grafeas.org>"]

Expand Down