Add system time zone designation. Closes #21.

This commit is contained in:
Ske 2018-12-18 19:38:53 +01:00
parent e8d1c5bf90
commit 570899928a
8 changed files with 89 additions and 13 deletions

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
from datetime import datetime
import discord import discord
import re import re
@ -37,14 +38,15 @@ class CommandError(Exception):
class CommandContext: class CommandContext:
def __init__(self, client: discord.Client, message: discord.Message, conn, args: str): def __init__(self, client: discord.Client, message: discord.Message, conn, args: str, system: Optional[System]):
self.client = client self.client = client
self.message = message self.message = message
self.conn = conn self.conn = conn
self.args = args self.args = args
self._system = system
async def get_system(self) -> Optional[System]: async def get_system(self) -> Optional[System]:
return await db.get_system_by_account(self.conn, self.message.author.id) return self._system
async def ensure_system(self) -> System: async def ensure_system(self) -> System:
system = await self.get_system() system = await self.get_system()
@ -57,6 +59,11 @@ class CommandContext:
def has_next(self) -> bool: def has_next(self) -> bool:
return bool(self.args) return bool(self.args)
def format_time(self, dt: datetime):
if self._system:
return self._system.format_time(dt)
return dt.isoformat(sep=" ", timespec="seconds") + " UTC"
def pop_str(self, error: CommandError = None) -> Optional[str]: def pop_str(self, error: CommandError = None) -> Optional[str]:
if not self.args: if not self.args:
if error: if error:
@ -211,7 +218,8 @@ async def command_dispatch(client: discord.Client, message: discord.Message, con
client=client, client=client,
message=message, message=message,
conn=conn, conn=conn,
args=remaining_string args=remaining_string,
system=await System.get_by_account(conn, message.author.id)
) )
await run_command(ctx, command_root) await run_command(ctx, command_root)
return True return True

View File

@ -124,14 +124,14 @@ async def switch_move(ctx: CommandContext):
last_fronters = await last_switch.fetch_members(ctx.conn) last_fronters = await last_switch.fetch_members(ctx.conn)
members = ", ".join([member.name for member in last_fronters]) or "nobody" members = ", ".join([member.name for member in last_fronters]) or "nobody"
last_absolute = last_switch.timestamp.isoformat(sep=" ", timespec="seconds") last_absolute = ctx.format_time(last_switch.timestamp)
last_relative = display_relative(last_switch.timestamp) last_relative = display_relative(last_switch.timestamp)
new_absolute = new_time.isoformat(sep=" ", timespec="seconds") new_absolute = ctx.format_time(new_time)
new_relative = display_relative(new_time) new_relative = display_relative(new_time)
# Confirm with user # Confirm with user
switch_confirm_message = await ctx.reply( switch_confirm_message = await ctx.reply(
"This will move the latest switch ({}) from {} UTC ({} ago) to {} UTC ({} ago). Is this OK?".format(members, "This will move the latest switch ({}) from {} ({} ago) to {} ({} ago). Is this OK?".format(members,
last_absolute, last_absolute,
last_relative, last_relative,
new_absolute, new_absolute,

View File

@ -2,6 +2,7 @@ from datetime import datetime, timedelta
import dateparser import dateparser
import humanize import humanize
import pytz
import pluralkit.bot.embeds import pluralkit.bot.embeds
from pluralkit.bot.commands import * from pluralkit.bot.commands import *
@ -29,6 +30,8 @@ async def system_root(ctx: CommandContext):
await system_fronthistory(ctx, await ctx.ensure_system()) await system_fronthistory(ctx, await ctx.ensure_system())
elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"): elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"):
await system_frontpercent(ctx, await ctx.ensure_system()) await system_frontpercent(ctx, await ctx.ensure_system())
elif ctx.match("timezone") or ctx.match("tz"):
await system_timezone(ctx)
elif ctx.match("set"): elif ctx.match("set"):
await system_set(ctx) await system_set(ctx)
elif not ctx.has_next(): elif not ctx.has_next():
@ -95,6 +98,16 @@ async def system_description(ctx: CommandContext):
await ctx.reply_ok("System description {}.".format("updated" if new_description else "cleared")) await ctx.reply_ok("System description {}.".format("updated" if new_description else "cleared"))
async def system_timezone(ctx: CommandContext):
system = await ctx.ensure_system()
new_tz = ctx.remaining() or None
tz = await system.set_time_zone(ctx.conn, new_tz)
offset = tz.utcoffset(datetime.utcnow())
offset_str = "UTC{:+02d}:{:02d}".format(int(offset.total_seconds() // 3600), int(offset.total_seconds() // 60 % 60))
await ctx.reply_ok("System time zone set to {} ({}, {}).".format(tz.tzname(datetime.utcnow()), offset_str, tz.zone))
async def system_tag(ctx: CommandContext): async def system_tag(ctx: CommandContext):
system = await ctx.ensure_system() system = await ctx.ensure_system()
new_tag = ctx.remaining() or None new_tag = ctx.remaining() or None
@ -196,14 +209,14 @@ async def system_fronthistory(ctx: CommandContext, system: System):
name = ", ".join([member.name for member in members]) name = ", ".join([member.name for member in members])
# Make proper date string # Make proper date string
time_text = timestamp.isoformat(sep=" ", timespec="seconds") time_text = ctx.format_time(timestamp)
rel_text = display_relative(timestamp) rel_text = display_relative(timestamp)
delta_text = "" delta_text = ""
if i > 0: if i > 0:
last_switch_time = front_history[i - 1][0] last_switch_time = front_history[i - 1][0]
delta_text = ", for {}".format(display_relative(timestamp - last_switch_time)) delta_text = ", for {}".format(display_relative(timestamp - last_switch_time))
lines.append("**{}** ({} UTC, {} ago{})".format(name, time_text, rel_text, delta_text)) lines.append("**{}** ({}, {} ago{})".format(name, time_text, rel_text, delta_text))
embed = embeds.status("\n".join(lines) or "(none)") embed = embeds.status("\n".join(lines) or "(none)")
embed.title = "Past switches" embed.title = "Past switches"
@ -302,6 +315,6 @@ async def system_frontpercent(ctx: CommandContext, system: System):
embed.add_field(name=member.name if member else "(no fronter)", embed.add_field(name=member.name if member else "(no fronter)",
value="{}% ({})".format(percent, humanize.naturaldelta(front_time))) value="{}% ({})".format(percent, humanize.naturaldelta(front_time)))
embed.set_footer(text="Since {} UTC ({} ago)".format(span_start.isoformat(sep=" ", timespec="seconds"), embed.set_footer(text="Since {} ({} ago)".format(ctx.format_time(span_start),
display_relative(span_start))) display_relative(span_start)))
await ctx.reply(embed=embed) await ctx.reply(embed=embed)

View File

@ -172,7 +172,7 @@ async def member_card(conn, member: Member) -> discord.Embed:
return card return card
async def front_status(switch: Switch, conn) -> discord.Embed: async def front_status(ctx: "CommandContext", switch: Switch, conn) -> discord.Embed:
if switch: if switch:
embed = status("") embed = status("")
fronter_names = [member.name for member in await switch.fetch_members(conn)] fronter_names = [member.name for member in await switch.fetch_members(conn)]
@ -186,7 +186,7 @@ async def front_status(switch: Switch, conn) -> discord.Embed:
if switch.timestamp: if switch.timestamp:
embed.add_field(name="Since", embed.add_field(name="Since",
value="{} UTC ({})".format(switch.timestamp.isoformat(sep=" ", timespec="seconds"), value="{} ({})".format(ctx.format_time(switch.timestamp),
display_relative(switch.timestamp))) display_relative(switch.timestamp)))
else: else:
embed = error("No switches logged.") embed = error("No switches logged.")

View File

@ -334,7 +334,8 @@ async def create_tables(conn):
tag text, tag text,
avatar_url text, avatar_url text,
token text, token text,
created timestamp not null default (current_timestamp at time zone 'utc') created timestamp not null default (current_timestamp at time zone 'utc'),
ui_tz text not null default 'UTC'
)""") )""")
await conn.execute("""create table if not exists members ( await conn.execute("""create table if not exists members (
id serial primary key, id serial primary key,

View File

@ -93,3 +93,8 @@ class MembersAlreadyFrontingError(PluralKitError):
class DuplicateSwitchMembersError(PluralKitError): class DuplicateSwitchMembersError(PluralKitError):
def __init__(self): def __init__(self):
super().__init__("Duplicate members in member list.") super().__init__("Duplicate members in member list.")
class InvalidTimeZoneError(PluralKitError):
def __init__(self, tz_name: str):
super().__init__("Invalid time zone designation \"{}\".\n\nFor a list of valid time zone designations, see the `TZ database name` column here: <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List>.".format(tz_name))

View File

@ -5,13 +5,29 @@ from collections.__init__ import namedtuple
from datetime import datetime from datetime import datetime
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
import pytz
from pluralkit import db, errors from pluralkit import db, errors
from pluralkit.member import Member from pluralkit.member import Member
from pluralkit.switch import Switch from pluralkit.switch import Switch
from pluralkit.utils import generate_hid, contains_custom_emoji, validate_avatar_url_or_raise from pluralkit.utils import generate_hid, contains_custom_emoji, validate_avatar_url_or_raise
class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "avatar_url", "token", "created"])): def canonicalize_tz_name(name: str) -> Optional[str]:
# First, try a direct search
try:
pytz.timezone(name)
return name
except pytz.UnknownTimeZoneError:
pass
# Then check last fragment of common time zone identifiers
name_map = {tz.split("/")[-1].replace("_", " "): tz for tz in pytz.common_timezones}
if name in name_map:
return name_map[name]
class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "avatar_url", "token", "created", "ui_tz"])):
id: int id: int
hid: str hid: str
name: str name: str
@ -20,6 +36,8 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a
avatar_url: str avatar_url: str
token: str token: str
created: datetime created: datetime
# pytz-compatible time zone name, usually Olson-style (eg. Europe/Amsterdam)
ui_tz: str
@staticmethod @staticmethod
async def get_by_id(conn, system_id: int) -> Optional["System"]: async def get_by_id(conn, system_id: int) -> Optional["System"]:
@ -206,6 +224,36 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a
inner_message = leading_mentions + inner_message inner_message = leading_mentions + inner_message
return member, inner_message return member, inner_message
def format_time(self, dt: datetime) -> str:
"""
Localizes the given `datetime` to a string based on the system's preferred time zone.
Assumes `dt` is a naïve `datetime` instance set to UTC, which is consistent with the rest of PluralKit.
"""
tz = pytz.timezone(self.ui_tz)
# Set to aware (UTC), convert to tz, set to naive (tz), then format and append name
return tz.normalize(pytz.utc.localize(dt)).replace(tzinfo=None).isoformat(sep=" ", timespec="seconds") + " " + tz.tzname(dt)
async def set_time_zone(self, conn, tz_name: str) -> pytz.tzinfo:
"""
Sets the system time zone to the time zone represented by the given string.
If `tz_name` is None or an empty string, will default to UTC.
If `tz_name` does not represent a valid time zone string, will raise InvalidTimeZoneError.
:raises: InvalidTimeZoneError
:returns: The `pytz.tzinfo` instance of the newly set time zone.
"""
canonical_name = canonicalize_tz_name(tz_name)
if not canonical_name:
raise errors.InvalidTimeZoneError(tz_name)
tz = pytz.timezone(canonical_name)
await db.update_system_field(conn, self.id, "ui_tz", tz.zone)
return tz
def to_json(self): def to_json(self):
return { return {
"id": self.hid, "id": self.hid,

View File

@ -6,3 +6,4 @@ https://github.com/Rapptz/discord.py/archive/860d6a9ace8248dfeec18b8b159e7b757d9
humanize humanize
uvloop; sys.platform != 'win32' and sys.platform != 'cygwin' and sys.platform != 'cli' uvloop; sys.platform != 'win32' and sys.platform != 'cygwin' and sys.platform != 'cli'
ciso8601 ciso8601
pytz