UI (accountability): rooms command improvements.

This commit is contained in:
2021-09-27 22:13:03 +03:00
parent 5ca10d5f74
commit ebeb3bec7a
3 changed files with 320 additions and 45 deletions

View File

@@ -6,6 +6,7 @@ import asyncio
from settings import GuildSettings from settings import GuildSettings
from utils.lib import tick, cross from utils.lib import tick, cross
from core import Lion from core import Lion
from meta import client
from .lib import utc_now from .lib import utc_now
from .data import accountability_members, accountability_rooms from .data import accountability_members, accountability_rooms
@@ -94,7 +95,9 @@ class TimeSlot:
), ),
colour=discord.Colour.orange(), colour=discord.Colour.orange(),
timestamp=self.start_time timestamp=self.start_time
).set_footer(text="About to start!") ).set_footer(
text="About to start!\nJoin the session with {}rooms book".format(client.prefix)
)
if self.members: if self.members:
embed.description = "Starting <t:{}:R>.".format(timestamp) embed.description = "Starting <t:{}:R>.".format(timestamp)
@@ -119,7 +122,7 @@ class TimeSlot:
description="Finishing <t:{}:R>.".format(timestamp + 3600), description="Finishing <t:{}:R>.".format(timestamp + 3600),
colour=discord.Colour.orange(), colour=discord.Colour.orange(),
timestamp=self.start_time timestamp=self.start_time
).set_footer(text="Running") ).set_footer(text="Join the next session using {}rooms book".format(client.prefix))
if self.members: if self.members:
classifications = { classifications = {

View File

@@ -4,9 +4,9 @@ import discord
import asyncio import asyncio
from cmdClient.checks import in_guild from cmdClient.checks import in_guild
from utils.lib import multiselect_regex, parse_ranges from utils.lib import multiselect_regex, parse_ranges, prop_tabulate
from data import NOTNULL from data import NOTNULL
from data.conditions import GEQ from data.conditions import GEQ, LEQ
from .module import module from .module import module
from .lib import utc_now from .lib import utc_now
@@ -16,6 +16,28 @@ from .TimeSlot import SlotMember
from .data import accountability_members, accountability_member_info, accountability_rooms from .data import accountability_members, accountability_member_info, accountability_rooms
hint_icon = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
def time_format(time):
diff = (time - utc_now()).total_seconds()
if diff < 0:
diffstr = "`Right Now!!`"
elif diff < 600:
diffstr = "`Very soon!!`"
elif diff < 3600:
diffstr = "`In <1 hour `"
else:
hours = round(diff / 3600)
diffstr = "`In {:>2} hour{}`".format(hours, 's' if hours > 1 else ' ')
return "{} | <t:{:.0f}:t> - <t:{:.0f}:t>".format(
diffstr,
time.timestamp(),
time.timestamp() + 3600,
)
@module.cmd( @module.cmd(
name="rooms", name="rooms",
desc="Book an accountability timeslot", desc="Book an accountability timeslot",
@@ -29,7 +51,7 @@ async def cmd_rooms(ctx):
{prefix}rooms book {prefix}rooms book
{prefix}rooms cancel {prefix}rooms cancel
Description: Description:
Book an accountability session timeslot. View, book, or cancel your accountability sessions.
""" """
lower = ctx.args.lower() lower = ctx.args.lower()
splits = lower.split() splits = lower.split()
@@ -51,27 +73,28 @@ async def cmd_rooms(ctx):
# Show unbooking menu # Show unbooking menu
lines = [ lines = [
"`[{:>2}]` | <t:{}:d> <t:{}:t> - <t:{}:t>".format( "`[{:>2}]` | {}".format(i, time_format(row['start_at']))
i,
int(row['start_at'].timestamp()),
int(row['start_at'].timestamp()),
int(row['start_at'].timestamp()) + 3600
)
for i, row in enumerate(joined_rows) for i, row in enumerate(joined_rows)
] ]
out_msg = await ctx.reply( out_msg = await ctx.reply(
content="Please reply with the number(s) of the rooms you want to cancel. E.g. `1, 3, 5` or `1-3, 7-8`.",
embed=discord.Embed( embed=discord.Embed(
title="Please choose the bookings you want to cancel.", title="Please choose the bookings you want to cancel.",
description='\n'.join(lines), description='\n'.join(lines),
colour=discord.Colour.orange() colour=discord.Colour.orange()
).set_footer( ).set_footer(
text=( text=(
"Reply with the number(s) of the rooms you want to join.\n" "All times are in your own timezone! Hover over a time to see the date."
"E.g. 1, 3, 5 or 1-5, 7-8."
) )
) )
) )
await ctx.cancellable(
out_msg,
cancel_message="Cancel menu closed, no accountability sessions were cancelled.",
timeout=70
)
def check(msg): def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c') valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
@@ -81,8 +104,14 @@ async def cmd_rooms(ctx):
message = await ctx.client.wait_for('message', check=check, timeout=60) message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: try:
await out_msg.delete() await out_msg.edit(
await ctx.error_reply("Session timed out. No accountability bookings were cancelled.") content=None,
embed=discord.Embed(
description="Cancel menu timed out, no accountability sessions were cancelled.",
colour=discord.Colour.red()
)
)
await out_msg.clear_reactions()
except discord.HTTPException: except discord.HTTPException:
pass pass
return return
@@ -112,6 +141,7 @@ async def cmd_rooms(ctx):
) )
# Handle case where the slot has already opened # Handle case where the slot has already opened
# TODO: Possible race condition if they open over the hour border? Might never cancel
for row in to_cancel: for row in to_cancel:
aguild = AGuild.cache.get(row['guildid'], None) aguild = AGuild.cache.get(row['guildid'], None)
if aguild and aguild.upcoming_slot and aguild.upcoming_slot.data: if aguild and aguild.upcoming_slot and aguild.upcoming_slot.data:
@@ -129,13 +159,29 @@ async def cmd_rooms(ctx):
break break
ctx.alion.addCoins(cost) ctx.alion.addCoins(cost)
await ctx.embed_reply(
"Successfully canceled your bookings."
)
# elif command == 'book':
else:
# Show booking menu
remaining = [row for row in joined_rows if row['slotid'] not in slotids]
if not remaining:
await ctx.embed_reply("Cancelled all your upcoming accountability sessions!")
else:
next_booked_time = min(row['start_at'] for row in remaining)
if len(to_cancel) > 1:
await ctx.embed_reply(
"Cancelled `{}` upcoming sessions!\nYour next session is at <t:{:.0f}>.".format(
len(to_cancel),
next_booked_time.timestamp()
)
)
else:
await ctx.embed_reply(
"Cancelled your session at <t:{:.0f}>!\n"
"Your next session is at <t:{:.0f}>.".format(
to_cancel[0]['start_at'].timestamp(),
next_booked_time.timestamp()
)
)
elif command == 'book':
# Show booking menu
# Get attendee count # Get attendee count
rows = accountability_member_info.select_where( rows = accountability_member_info.select_where(
guildid=ctx.guild.id, guildid=ctx.guild.id,
@@ -157,29 +203,34 @@ async def cmd_rooms(ctx):
start_time + datetime.timedelta(hours=n) start_time + datetime.timedelta(hours=n)
for n in range(1, 25) for n in range(1, 25)
) )
times = list(time for time in times if time not in already_joined_times) times = [time for time in times if time not in already_joined_times]
lines = [ lines = [
"`[{:>2}]` | `{:>{}}` attending | <t:{}:d> <t:{}:t> - <t:{}:t>".format( "`[{num:>2}]` | `{count:>{count_pad}}` attending | {time}".format(
i, num=i,
attendees.get(time, 0), attendee_pad, count=attendees.get(time, 0), count_pad=attendee_pad,
int(time.timestamp()), int(time.timestamp()), int(time.timestamp()) + 3600 time=time_format(time),
) )
for i, time in enumerate(times) for i, time in enumerate(times)
] ]
# TODO: Nicer embed # TODO: Nicer embed
# TODO: Don't allow multi bookings if the member has a bad attendence rate # TODO: Don't allow multi bookings if the member has a bad attendance rate
out_msg = await ctx.reply( out_msg = await ctx.reply(
content="Please reply with the number(s) of the rooms you want to join. E.g. `1, 3, 5` or `1-3, 7-8`.",
embed=discord.Embed( embed=discord.Embed(
title="Please choose the sessions you want to book.", title="Please choose the sessions you want to book.",
description='\n'.join(lines), description='\n'.join(lines),
colour=discord.Colour.orange() colour=discord.Colour.orange()
).set_footer( ).set_footer(
text=( text=(
"Reply with the number(s) of the rooms you want to join.\n" "All times are in your own timezone! Hover over a time to see the date."
"E.g. 1, 3, 5 or 1-5, 7-8."
) )
) )
) )
await ctx.cancellable(
out_msg,
cancel_message="Booking menu cancelled, no sessions were booked.",
timeout=70
)
def check(msg): def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author valid = msg.channel == ctx.ch and msg.author == ctx.author
@@ -190,12 +241,19 @@ async def cmd_rooms(ctx):
message = await ctx.client.wait_for('message', check=check, timeout=60) message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: try:
await out_msg.delete() await out_msg.edit(
await ctx.error_reply("Session timed out. No accountability slots were booked.") content=None,
embed=discord.Embed(
description="Booking menu timed out, no sessions were booked.",
colour=discord.Colour.red()
)
)
await out_msg.clear_reactions()
except discord.HTTPException: except discord.HTTPException:
pass pass
return return
print(message)
try: try:
await out_msg.delete() await out_msg.delete()
await message.delete() await message.delete()
@@ -260,21 +318,224 @@ async def cmd_rooms(ctx):
pass pass
await slot.update_status() await slot.update_status()
ctx.alion.addCoins(-cost) ctx.alion.addCoins(-cost)
await ctx.embed_reply(
"You have booked the following accountability sessions.\n{}".format( # Ack purchase
'\n'.join( embed = discord.Embed(
"<t:{}:d> <t:{}:t> - <t:{}:t>".format( title="You have booked the following session{}!".format('s' if len(to_book) > 1 else ''),
int(time.timestamp()), int(time.timestamp()), int(time.timestamp() + 3600) description=(
) for time in to_book "*Please attend all your booked sessions!*\n"
) "*If you can't attend, cancel with* `{}rooms cancel`\n\n{}"
) ).format(
ctx.best_prefix,
'\n'.join(time_format(time) for time in to_book),
),
colour=discord.Colour.orange()
).set_footer(
text=(
"Use {prefix}rooms to see your current bookings.\n"
).format(prefix=ctx.best_prefix)
) )
# else: try:
# # Show current booking information await ctx.reply(
# embed = discord.Embed( embed=embed,
# title="Accountability Room Information" reference=ctx.msg
# ) )
# ... except discord.NotFound:
await ctx.reply(embed=embed)
else:
# Show accountability room information for this user
# Accountability profile
# Author
# Special case for no past bookings, emphasis hint
# Hint on Bookings section for booking/cancelling as applicable
# Description has stats
# Footer says that all times are in their timezone
# TODO: attendance requirement shouldn't be retroactive! Add attended data column
# Attended `{}` out of `{}` booked (`{}%` attendance rate!)
# Attendance streak: `{}` days attended with no missed sessions!
# Add explanation for first time users
# Get all slots the member has ever booked
history = accountability_member_info.select_where(
userid=ctx.author.id,
# start_at=LEQ(utc_now() - datetime.timedelta(hours=1)),
start_at=LEQ(utc_now()),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
_extra="ORDER BY start_at DESC"
)
if not (history or joined_rows):
# First-timer information
about = (
"You haven't joined any accountability sessions yet!\n"
"Book a session by typing **`{}rooms book`** and selecting "
"the hours you intend to study, "
"then attend by joining the accountability voice channel when the session starts!\n"
"Only if everyone attends will they get the bonus of `{}` LionCoins!\n"
"Let's all do our best and keep each other accountable 🔥"
).format(
ctx.best_prefix,
ctx.guild_settings.accountability_bonus.value
)
embed = discord.Embed(
description=about,
colour=discord.Colour.orange()
)
embed.set_footer(
text="Please keep your DMs open so I can notify you when the session starts!\n",
icon_url=hint_icon
)
await ctx.reply(embed=embed)
else:
# Build description with stats
if history:
# First get the counts
attended_count = sum(row['attended'] for row in history)
total_count = len(history)
total_duration = sum(row['duration'] for row in history)
# Add current session to duration if it exists
if history[0]['last_joined_at'] and (utc_now() - history[0]['start_at']).total_seconds() < 3600:
total_duration += int((utc_now() - history[0]['last_joined_at']).total_seconds())
# Calculate the streak
timezone = ctx.alion.settings.timezone.value
streak = 0
current_streak = None
max_streak = 0
day_attended = None
date = utc_now().astimezone(timezone).replace(hour=0, minute=0, second=0, microsecond=0)
daydiff = datetime.timedelta(days=1)
i = 0
while i < len(history):
row = history[i]
print(date, row['start_at'])
i += 1
if not row['attended']:
# Not attended, streak broken
pass
elif row['start_at'] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
date -= daydiff
day_attended = False
i -= 1
continue
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
# Build the stats
table = {
"Sessions": "**{}** attended out of **{}**, `{:.0f}%` attendance rate.".format(
attended_count,
total_count,
(attended_count * 100) / total_count,
),
"Time": "**{:02}:{:02}** spent in accountability rooms.".format(
total_duration // 60,
total_duration % 60
),
"Streak": "**{}** day{} with no missed sessions! (Longest: **{}** day{}.)".format(
current_streak,
's' if current_streak != 1 else '',
max_streak,
's' if max_streak != 1 else '',
),
}
desc = prop_tabulate(*zip(*table.items()))
else:
desc = (
"Good luck with your next session!\n"
)
# Build currently booked list
if joined_rows:
# TODO: (Future) calendar link
# Get attendee counts for currently booked sessions
rows = accountability_member_info.select_where(
slotid=[row["slotid"] for row in joined_rows],
userid=NOTNULL,
select_columns=(
'slotid',
'start_at',
'COUNT(*) as num'
),
_extra="GROUP BY start_at, slotid ORDER BY start_at ASC"
)
attendees = {row['start_at']: row['num'] for row in rows}
attendee_pad = max((len(str(num)) for num in attendees.values()), default=1)
# TODO: Allow cancel to accept multiselect keys as args
booked_list = '\n'.join(
"`{:>{}}` attendees | {}".format(
num,
attendee_pad,
time_format(start)
) for start, num in attendees.items()
)
booked_field = (
"{}\n\n"
"*If you can't make your booking, please cancel using `{}rooms cancel`!*"
).format(booked_list, ctx.best_prefix)
# Temporary footer for acclimatisation
# footer = "All times are displayed in your own timezone!"
footer = "Book another session using {}rooms book".format(ctx.best_prefix)
else:
booked_field = (
"Your schedule is empty!\n"
"Book another session using `{}rooms book`."
).format(ctx.best_prefix)
footer = "Please keep your DMs open for notifications!"
# Finally, build embed
embed = discord.Embed(
colour=discord.Colour.orange(),
description=desc,
).set_author(
name="Accountability profile for {}".format(ctx.author.name),
icon_url=ctx.author.avatar_url
).set_footer(
text=footer,
icon_url=hint_icon
).add_field(
name="Upcoming sessions",
value=booked_field
)
# And send it!
await ctx.reply(embed=embed)
# TODO: roomadmin # TODO: roomadmin

View File

@@ -21,3 +21,14 @@ accountability_members = RowTable(
accountability_member_info = Table('accountability_member_info') accountability_member_info = Table('accountability_member_info')
accountability_open_slots = Table('accountability_open_slots') accountability_open_slots = Table('accountability_open_slots')
# @accountability_member_info.save_query
# def user_streaks(userid, min_duration):
# with accountability_member_info.conn as conn:
# cursor = conn.cursor()
# with cursor:
# cursor.execute(
# """
# """
# )