diff --git a/general/betheprofessional/cog.py b/general/betheprofessional/cog.py index ad27c462a..685ed811f 100644 --- a/general/betheprofessional/cog.py +++ b/general/betheprofessional/cog.py @@ -1,121 +1,223 @@ import string -from typing import List -from discord import Embed, Guild, Member, Role -from discord.ext import commands +from discord import Embed, Member, Message, Role +from discord.ext import commands, tasks from discord.ext.commands import CommandError, Context, UserInputError, guild_only +import PyDrocsid from PyDrocsid.cog import Cog -from PyDrocsid.command import reply -from PyDrocsid.database import db, select +from PyDrocsid.command import Confirmation, reply +from PyDrocsid.database import db, db_wrapper, delete, filter_by, select from PyDrocsid.embeds import send_long_embed +from PyDrocsid.environment import CACHE_TTL +from PyDrocsid.logger import get_logger +from PyDrocsid.redis import redis from PyDrocsid.translations import t -from PyDrocsid.util import calculate_edit_distance, check_role_assignable +from PyDrocsid.util import calculate_edit_distance from .colors import Colors -from .models import BTPRole +from .models import BTPTopic, BTPUser from .permissions import BeTheProfessionalPermission +from .settings import BeTheProfessionalSettings from ...contributor import Contributor -from ...pubsub import send_to_changelog +from ...pubsub import send_alert, send_to_changelog tg = t.g t = t.betheprofessional +logger = get_logger(__name__) -def split_topics(topics: str) -> List[str]: +LEADERBOARD_TABLE_SPACING = 2 + + +def split_topics(topics: str) -> list[str]: + # TODO docstring return [topic for topic in map(str.strip, topics.replace(";", ",").split(",")) if topic] -async def parse_topics(guild: Guild, topics: str, author: Member) -> List[Role]: - roles: List[Role] = [] - all_topics: List[Role] = await list_topics(guild) - for topic in split_topics(topics): - for role in guild.roles: - if role.name.lower() == topic.lower(): - if role in all_topics: - break - if not role.managed and role >= guild.me.top_role: - raise CommandError(t.youre_not_the_first_one(topic, author.mention)) - else: - if all_topics: - best_dist, best_match = min( - (calculate_edit_distance(r.name.lower(), topic.lower()), r.name) for r in all_topics - ) - if best_dist <= 5: - raise CommandError(t.topic_not_found_did_you_mean(topic, best_match)) +async def split_parents(topics: list[str], assignable: bool) -> list[tuple[str, bool, list[BTPTopic]]]: + # TODO docstring + result: list[tuple[str, bool, list[BTPTopic]] | None] = [] + for topic in topics: + topic_tree = topic.split("/") - raise CommandError(t.topic_not_found(topic)) + parents: list[BTPTopic | None] = [] + for par in topic_tree[:-1]: + parents.append(parent := await db.first(filter_by(BTPTopic, name=par))) # TODO redis? + if parent is None: + raise CommandError(t.parent_not_exists(topic)) - roles.append(role) + result.append((topic_tree[-1], assignable, parents)) + return result - return roles +async def parse_topics(topics_str: str) -> list[BTPTopic]: + # TODO docstring + topics: list[BTPTopic] = [] + all_topics: list[BTPTopic] = await get_topics() -async def list_topics(guild: Guild) -> List[Role]: - roles: List[Role] = [] - async for btp_role in await db.stream(select(BTPRole)): - if (role := guild.get_role(btp_role.role_id)) is None: - await db.delete(btp_role) - else: - roles.append(role) - return roles - - -async def unregister_roles(ctx: Context, topics: str, *, delete_roles: bool): - guild: Guild = ctx.guild - roles: List[Role] = [] - btp_roles: List[BTPRole] = [] - names = split_topics(topics) - if not names: - raise UserInputError - - for topic in names: - for role in guild.roles: - if role.name.lower() == topic.lower(): - break - else: - raise CommandError(t.topic_not_registered(topic)) - if (btp_role := await db.first(select(BTPRole).filter_by(role_id=role.id))) is None: - raise CommandError(t.topic_not_registered(topic)) + if len(all_topics) == 0: + raise CommandError(t.no_topics_registered) + + for topic_name in split_topics(topics_str): + topic = await db.first(filter_by(BTPTopic, name=topic_name)) # TODO db obsolete + + if topic is None and len(all_topics) > 0: + best_dist, best_match = min( + (calculate_edit_distance(r.name.lower(), topic_name.lower()), r.name) for r in all_topics + ) + if best_dist <= 5: + raise CommandError(t.topic_not_found_did_you_mean(topic_name, best_match)) - roles.append(role) - btp_roles.append(btp_role) + raise CommandError(t.topic_not_found(topic_name)) + elif topic is None: + raise CommandError(t.no_topics_registered) + topics.append(topic) - for role, btp_role in zip(roles, btp_roles): - if delete_roles: - check_role_assignable(role) - await role.delete() - await db.delete(btp_role) + return topics - embed = Embed(title=t.betheprofessional, colour=Colors.BeTheProfessional) - embed.description = t.topics_unregistered(cnt=len(roles)) - await send_to_changelog( - ctx.guild, t.log_topics_unregistered(cnt=len(roles), topics=", ".join(f"`{r}`" for r in roles)) - ) - await send_long_embed(ctx, embed) + +async def get_topics() -> list[BTPTopic]: + # TODO docstring + return await db.all(select(BTPTopic)) + + +async def change_setting(ctx: Context, name: str, value: any): + # TODO docstring + data = t.settings[name] + await getattr(BeTheProfessionalSettings, name).set(value) + + embed = Embed(title=t.betheprofessional, color=Colors.green) + embed.description = data["updated"].format(value) + + await reply(ctx, embed=embed) + await send_to_changelog(ctx.guild, embed.description) class BeTheProfessionalCog(Cog, name="BeTheProfessional"): - CONTRIBUTORS = [Contributor.Defelo, Contributor.wolflu, Contributor.MaxiHuHe04, Contributor.AdriBloober] + CONTRIBUTORS = [ + Contributor.Defelo, + Contributor.wolflu, + Contributor.MaxiHuHe04, + Contributor.AdriBloober, + Contributor.Tert0, + ] + + async def on_ready(self): + self.update_roles.cancel() + try: + self.update_roles.start() + except RuntimeError: + self.update_roles.restart() + + @tasks.loop(hours=24) + @db_wrapper + async def update_roles(self): + role_create_min_users = await BeTheProfessionalSettings.RoleCreateMinUsers.get() + + logger.info("Started Update Role Loop") + topic_count: dict[int, int] = {} + + for topic in await db.all(select(BTPTopic).order_by(BTPTopic.id.asc())): + if len(topic.users) >= role_create_min_users: + topic_count[topic.id] = len(topic.users) + + # Sort Topics By Count and Limit Roles to BeTheProfessionalSettings.RoleLimit + top_topics: list[int] = sorted(topic_count, key=lambda x: topic_count[x], reverse=True)[ + : await BeTheProfessionalSettings.RoleLimit.get() + ] + + new_roles_topic_id: list[int] = [] + + # Delete old Top Topic Roles + for topic in await db.all( + select(BTPTopic).filter(BTPTopic.role_id.is_not(None), BTPTopic.id.not_in(top_topics)) + ): # type: BTPTopic + await self.bot.guilds[0].get_role(topic.role_id).delete() + topic.role_id = None + + # Create new Topic Roles + roles: dict[int, Role] = {} + for topic in await db.all( + select(BTPTopic).filter(BTPTopic.id.in_(top_topics), BTPTopic.role_id.is_(None)) + ): # type: BTPTopic + new_roles_topic_id.append(topic.id) + role = await self.bot.guilds[0].create_role(name=topic.name) + topic.role_id = role.id + roles[topic.id] = role + + # Iterate over all members(with topics) and add the role to them + member_ids: set[int] = { + btp_user.user_id for btp_user in await db.all(select(BTPUser)) if btp_user.topic.id in new_roles_topic_id + } + for member_id in member_ids: + member: Member = self.bot.guilds[0].get_member(member_id) + if member is None: + continue + member_roles: list[Role] = [ + roles.get(btp_user.topic) for btp_user in await db.all(select(BTPUser).filter_by(user_id=member_id)) + ] + member_roles = list(filter(lambda x: x is not None, member_roles)) + await member.add_roles(*member_roles, atomic=False) + + logger.info("Created Top Topic Roles") + + async def on_member_join(self, member: Member): + roles: list[Role] = [ + self.bot.guilds[0].get_role(topic.role_id) + async for topic in await db.stream(select(BTPUser).filter_by(user_id=member.id)) + ] + await member.add_roles(*roles, atomic=False) @commands.command(name="?") @guild_only() - async def list_topics(self, ctx: Context): + async def list_topics(self, ctx: Context, parent_topic: str | None): """ - list all registered topics + list all direct children topics of the parent """ + parent: BTPTopic | None | CommandError = ( + None + if parent_topic is None + else await db.first(filter_by(BTPTopic, name=parent_topic)) + or CommandError(t.topic_not_found(parent_topic)) # noqa: W503 + ) + if isinstance(parent, CommandError): + raise parent embed = Embed(title=t.available_topics_header, colour=Colors.BeTheProfessional) - out = [role.name for role in await list_topics(ctx.guild)] - if not out: + sorted_topics: dict[str, list[str]] = {} + topics: list[BTPTopic] = await db.all(filter_by(BTPTopic, parent_id=None if parent is None else parent.id)) + if not topics: embed.colour = Colors.error embed.description = t.no_topics_registered await reply(ctx, embed=embed) return - out.sort(key=str.lower) - embed.description = ", ".join(f"`{topic}`" for topic in out) + topics.sort(key=lambda btp_topic: btp_topic.name.lower()) + root_topic: BTPTopic | None = ( + None if parent_topic is None else await db.first(filter_by(BTPTopic, name=parent_topic)) + ) + for topic in topics: + if (root_topic.name if root_topic is not None else "Topics") not in sorted_topics.keys(): + sorted_topics[root_topic.name if root_topic is not None else "Topics"] = [f"{topic.name}"] + else: + sorted_topics[root_topic.name if root_topic is not None else "Topics"].append(f"{topic.name}") + + for root_topic in sorted_topics.keys(): + embed.add_field( + name=root_topic, + value=", ".join( + [ + f"`{topic.name}" + + ( # noqa: W503 + f" ({c})`" if (c := await db.count(filter_by(BTPTopic, parent_id=topic.id))) > 0 else "`" + ) + for topic in topics + ] + ), + inline=False, + ) await send_long_embed(ctx, embed) @commands.command(name="+") @@ -126,17 +228,39 @@ async def assign_topics(self, ctx: Context, *, topics: str): """ member: Member = ctx.author - roles: List[Role] = [r for r in await parse_topics(ctx.guild, topics, ctx.author) if r not in member.roles] + topics: list[BTPTopic] = [ + topic + for topic in await parse_topics(topics) + if (await db.exists(filter_by(BTPTopic, id=topic.id))) # TODO db obsolete + and not (await db.exists(filter_by(BTPUser, user_id=member.id, topic_id=topic.id))) # noqa: W503 + ] - for role in roles: - check_role_assignable(role) + roles: list[Role] = [] - await member.add_roles(*roles, atomic=False) + for topic in topics: + await BTPUser.create(member.id, topic.id) + if topic.role_id: + roles.append(ctx.guild.get_role(topic.role_id)) + await ctx.author.add_roles(*roles, atomic=False) embed = Embed(title=t.betheprofessional, colour=Colors.BeTheProfessional) - embed.description = t.topics_added(cnt=len(roles)) - if not roles: + embed.description = t.topics_added(cnt=len(topics)) + + redis_key: str = f"btp:single_un_assign:{ctx.author.id}" + + if len(topics) == 0: embed.colour = Colors.error + elif len(topics) == 1: + count = await redis.incr(redis_key) + await redis.expire(redis_key, 30) + + if count > 3: + await reply(ctx, embed=embed) + + embed.colour = Colors.BeTheProfessional + embed.description = t.single_un_assign_help + else: + await redis.delete(redis_key) await reply(ctx, embed=embed) @@ -146,88 +270,317 @@ async def unassign_topics(self, ctx: Context, *, topics: str): """ remove one or more topics (use * to remove all topics) """ - member: Member = ctx.author if topics.strip() == "*": - roles: List[Role] = await list_topics(ctx.guild) + topics: list[BTPTopic] = await get_topics() else: - roles: List[Role] = await parse_topics(ctx.guild, topics, ctx.author) - roles = [r for r in roles if r in member.roles] + topics: list[BTPTopic] = await parse_topics(topics) + affected_topics: list[BTPTopic] = [] + for topic in topics: + if await db.exists(filter_by(BTPUser, user_id=member.id, topic_id=topic.id)): + affected_topics.append(topic) + + roles: list[Role] = [] - for role in roles: - check_role_assignable(role) + for topic in affected_topics: + await db.delete(await db.first(filter_by(BTPUser, topic_id=topic.id))) + if topic.role_id: + roles.append(ctx.guild.get_role(topic.role_id)) - await member.remove_roles(*roles, atomic=False) + await ctx.author.remove_roles(*roles, atomic=False) embed = Embed(title=t.betheprofessional, colour=Colors.BeTheProfessional) - embed.description = t.topics_removed(cnt=len(roles)) + embed.description = t.topics_removed(cnt=len(affected_topics)) + + redis_key: str = f"btp:single_un_assign:{ctx.author.id}" + + if len(affected_topics) == 1: + count = await redis.incr(redis_key) + await redis.expire(redis_key, 30) + + if count > 3: + await redis.delete(redis_key) + await reply(ctx, embed=embed) + + embed.description = t.single_un_assign_help + elif len(affected_topics) > 1: + await redis.delete(redis_key) + await reply(ctx, embed=embed) @commands.command(name="*") @BeTheProfessionalPermission.manage.check @guild_only() - async def register_topics(self, ctx: Context, *, topics: str): + async def register_topics(self, ctx: Context, *, topic_paths: str, assignable: bool = True): """ - register one or more new topics + register one or more new topics by path """ - guild: Guild = ctx.guild - names = split_topics(topics) - if not names: + names = split_topics(topic_paths) + topic_paths: list[tuple[str, bool, list[BTPTopic]]] = await split_parents(names, assignable) + if not names or not topic_paths: raise UserInputError - valid_chars = set(string.ascii_letters + string.digits + " !#$%&'()+-./:<=>?[\\]^_{|}~") - to_be_created: List[str] = [] - roles: List[Role] = [] - for topic in names: - if len(topic) > 100: + valid_chars = set(string.ascii_letters + string.digits + " !#$%&'()+-./:<=>?[\\]^_`{|}~") + registered_topics: list[tuple[str, bool, list[BTPTopic]] | None] = [] + for topic in topic_paths: + if len(topic) > 100: # TODO raise CommandError(t.topic_too_long(topic)) - if any(c not in valid_chars for c in topic): + if any(c not in valid_chars for c in topic[0]): raise CommandError(t.topic_invalid_chars(topic)) - for role in guild.roles: - if role.name.lower() == topic.lower(): - break + if await db.exists(filter_by(BTPTopic, name=topic[0])): + raise CommandError(t.topic_already_registered(topic[0])) else: - to_be_created.append(topic) - continue + registered_topics.append(topic) - if await db.exists(select(BTPRole).filter_by(role_id=role.id)): - raise CommandError(t.topic_already_registered(topic)) + for registered_topic in registered_topics: + await BTPTopic.create( + registered_topic[0], + None, + assignable, + registered_topic[2][-1].id if len(registered_topic[2]) > 0 else None, + ) - check_role_assignable(role) + embed = Embed(title=t.betheprofessional, colour=Colors.BeTheProfessional) + embed.description = t.topics_registered(cnt=len(registered_topics)) + await send_to_changelog( + ctx.guild, + t.log_topics_registered( + cnt=len(registered_topics), topics=", ".join(f"`{r[0]}`" for r in registered_topics) + ), + ) + await reply(ctx, embed=embed) - roles.append(role) + @commands.command(name="/") + @BeTheProfessionalPermission.manage.check + @guild_only() + async def delete_topics(self, ctx: Context, *, topics: str): + """ + delete one or more topics + """ + + topics: list[str] = split_topics(topics) - for name in to_be_created: - roles.append(await guild.create_role(name=name, mentionable=True)) + for topic in topics: + if not await db.exists(filter_by(BTPTopic, name=topic)): + raise CommandError(t.topic_not_registered(topic)) - for role in roles: - await BTPRole.create(role.id) + if not await Confirmation(danger=True).run(ctx, t.confirm_delete_topics(topics=", ".join(topics))): + return + for topic in topics: + await db.exec(delete(BTPTopic).where(BTPTopic.name == topic)) embed = Embed(title=t.betheprofessional, colour=Colors.BeTheProfessional) - embed.description = t.topics_registered(cnt=len(roles)) + embed.description = t.topics_unregistered(cnt=len(topics)) await send_to_changelog( - ctx.guild, t.log_topics_registered(cnt=len(roles), topics=", ".join(f"`{r}`" for r in roles)) + ctx.guild, t.log_topics_unregistered(cnt=len(topics), topics=", ".join(f"`{t}`" for t in topics)) ) + await send_long_embed(ctx, embed) + + @commands.command() + @guild_only() + async def topic(self, ctx: Context, topic_name: str, message: Message | None): + """ + pings the specified topic + """ + + topic: BTPTopic = await db.first(select(BTPTopic).filter_by(name=topic_name)) + mention: str + if topic is None: + raise CommandError(t.topic_not_found(topic_name)) + if topic.role_id is not None: + mention = f"<@&{topic.role_id}>" + else: + topic_members: list[BTPUser] = await db.all(select(BTPUser).filter_by(topic_id=topic.id)) + mention = ", ".join([f"<@{m.user_id}>" for m in topic_members]) + + if mention == "": + raise CommandError(t.nobody_has_topic(topic_name)) + if message is None: + await ctx.send(mention) + else: + await message.reply(mention) + + @commands.group() + @guild_only() + @BeTheProfessionalPermission.read.check + async def btp(self, ctx: Context): + if ctx.subcommand_passed is not None: + if ctx.invoked_subcommand is None: + raise UserInputError + return + + embed = Embed(title=t.betheprofessional, color=Colors.BeTheProfessional) + + for setting_item in ["RoleLimit", "RoleCreateMinUsers", "LeaderboardDefaultN", "LeaderboardMaxN"]: + data = getattr(t.settings, setting_item) + embed.add_field( + name=data.name, value=await getattr(BeTheProfessionalSettings, setting_item).get(), inline=False + ) await reply(ctx, embed=embed) - @commands.command(name="/") + @btp.command() + @guild_only() @BeTheProfessionalPermission.manage.check + async def role_limit(self, ctx: Context, role_limit: int): + """ + changes the btp role limit + """ + + if role_limit <= 0: + raise CommandError(t.must_be_above_zero(t.settings.role_limit.name)) + await change_setting(ctx, "RoleLimit", role_limit) + + @btp.command() @guild_only() - async def delete_topics(self, ctx: Context, *, topics: str): + @BeTheProfessionalPermission.manage.check + async def role_create_min_users(self, ctx: Context, role_create_min_users: int): """ - delete one or more topics + changes the btp role create min users count """ - await unregister_roles(ctx, topics, delete_roles=True) + if role_create_min_users < 0: + raise CommandError(t.must_be_zero_or_above(t.settings.role_create_min_users.name)) + await change_setting(ctx, "RoleCreateMinUsers", role_create_min_users) - @commands.command(name="%") + @btp.command() + @guild_only() @BeTheProfessionalPermission.manage.check + async def leaderboard_default_n(self, ctx: Context, leaderboard_default_n: int): + """ + changes the btp leaderboard default n + """ + + if leaderboard_default_n <= 0: + raise CommandError(t.must_be_above_zero(t.settings.leaderboard_default_n.name)) + await change_setting(ctx, "LeaderboardDefaultN", leaderboard_default_n) + + @btp.command() @guild_only() - async def unregister_topics(self, ctx: Context, *, topics: str): + @BeTheProfessionalPermission.manage.check + async def leaderboard_max_n(self, ctx: Context, leaderboard_max_n: int): + """ + changes the btp leaderboard max n + """ + + if leaderboard_max_n <= 0: + raise CommandError(t.must_be_above_zero(t.settings.leaderboard_max_n.name)) + await change_setting(ctx, "LeaderboardMaxN", leaderboard_max_n) + + @btp.command(aliases=["lb"]) + @guild_only() + # TODO parameters + async def leaderboard(self, ctx: Context, n: int | None = None, use_cache: bool = True): + """ + lists the top n topics + """ + + default_n = await BeTheProfessionalSettings.LeaderboardDefaultN.get() + max_n = await BeTheProfessionalSettings.LeaderboardMaxN.get() + if n is None: + n = default_n + if default_n > max_n: + await send_alert(ctx.guild, t.leaderboard_default_n_bigger_than_max_n) + raise CommandError(t.leaderboard_configuration_error) + if n > max_n and not await BeTheProfessionalPermission.bypass_leaderboard_n_limit.check_permissions(ctx.author): + raise CommandError(t.leaderboard_n_too_big(n, max_n)) + if n <= 0: + raise CommandError(t.leaderboard_n_zero_error) + + cached_leaderboard_parts: list[str] | None = None + + redis_key = f"btp:leaderboard:n:{n}" + if use_cache: + if not await BeTheProfessionalPermission.bypass_leaderboard_cache.check_permissions(ctx.author): + raise CommandError(t.missing_cache_bypass_permission) + cached_leaderboard_parts = await redis.lrange(redis_key, 0, await redis.llen(redis_key)) + + leaderboard_parts: list[str] = [] + if not cached_leaderboard_parts: + topic_count: dict[int, int] = {} + + for topic in await db.all(select(BTPTopic)): + topic_count[topic.id] = await db.count(select(BTPUser).filter_by(topic_id=topic.id)) + + top_topics: list[int] = sorted(topic_count, key=lambda x: topic_count[x], reverse=True)[:n] + + if len(top_topics) == 0: + raise CommandError(t.no_topics_registered) + + name_field = t.leaderboard_colmn_name + users_field = t.leaderboard_colmn_users + + rank_len = len(str(len(top_topics))) + 1 + name_len = max(max([len(topic.name) for topic in await db.all(select(BTPTopic))]), len(name_field)) + + rank_spacing = " " * (rank_len + LEADERBOARD_TABLE_SPACING) + name_spacing = " " * (name_len + LEADERBOARD_TABLE_SPACING - len(name_field)) + + header: str = f"{rank_spacing}{name_field}{name_spacing}{users_field}" + + current_part: str = header + for i, topic_id in enumerate(top_topics): + topic: BTPTopic = await db.first(select(BTPTopic).filter_by(id=topic_id)) + users: int = topic_count[topic_id] + name: str = topic.name.ljust(name_len, " ") + rank: str = "#" + str(i + 1).rjust(rank_len - 1, "0") + current_line = f"{rank}{' ' * LEADERBOARD_TABLE_SPACING}{name}{' ' * LEADERBOARD_TABLE_SPACING}{users}" + if current_part == "": + current_part = current_line + else: + if len(current_part + "\n" + current_line) + 9 > PyDrocsid.embeds.EmbedLimits.FIELD_VALUE: + leaderboard_parts.append(current_part) + current_part = current_line + else: + current_part += "\n" + current_line + if current_part != "": + leaderboard_parts.append(current_part) + + for part in leaderboard_parts: + await redis.lpush(redis_key, part) + await redis.expire(redis_key, CACHE_TTL) + else: + leaderboard_parts = cached_leaderboard_parts + + embed = Embed(title=t.leaderboard_title(n)) + for part in leaderboard_parts: + embed.add_field(name="** **", value=f"```css\n{part}\n```", inline=False) + await send_long_embed(ctx, embed, paginate=True) + + @commands.command(name="usertopics", aliases=["usertopic", "utopics", "utopic"]) + async def user_topics(self, ctx: Context, member: Member | None): + """ + lists all topics of a member + """ + + if member is None: + member = ctx.author + + topics_assignments: list[BTPUser] = await db.all(select(BTPUser).filter_by(user_id=member.id)) + + embed = Embed(title=t.betheprofessional, color=Colors.BeTheProfessional) + + embed.set_author(name=str(member), icon_url=member.display_avatar.url) + + topics_str: str = "" + + if len(topics_assignments) == 0: + embed.colour = Colors.red + else: + topics_str = ", ".join([f"`{topics_assignment.topic.name}`" for topics_assignment in topics_assignments]) + + embed.description = t.user_topics(member.mention, topics_str, cnt=len(topics_assignments)) + + await reply(ctx, embed=embed) + + @commands.command(aliases=["topic_update", "update_roles"]) + @guild_only() + @BeTheProfessionalPermission.manage.check + async def topic_update_roles(self, ctx: Context): """ - unregister one or more topics without deleting the roles + updates the topic roles manually """ - await unregister_roles(ctx, topics, delete_roles=False) + await self.update_roles() + await reply(ctx, "Updated Topic Roles") diff --git a/general/betheprofessional/documentation.md b/general/betheprofessional/documentation.md index df672bb75..61eefd883 100644 --- a/general/betheprofessional/documentation.md +++ b/general/betheprofessional/documentation.md @@ -1,45 +1,58 @@ # BeTheProfessional -Contains a system for self-assignable roles (further referred to as `topics`). +Contains a system for self-assignable topics ## `?` (list topics) -Lists all available topics. +The `.?` command lists all available topics at the level `parent_topic`. + +By default `parent_topic` is the Root Level. ```css -.? +.? [parent_topic] ``` +Arguments: +| Argument | Required | Description | +|:--------------:|:--------:|:-----------------------| +| `parent_topic` | | Parent Level of Topics | + ## `+` (assign topics) -Assigns the user the specified topics. +The `.+` command assigns the user the specified topics. + + +!!! important + Use only the topic name! Not the Path! ```css -.+ +.+ ``` Arguments: - -| Argument | Required | Description | -|:--------:|:-------------------------:|:---------------------------------------------| -| `topics` | :fontawesome-solid-check: | One or more topics (separated by `,` or `;`) | +| Argument | Required | Description | +|:--------:|:-------------------------:|:-------------------------------------------------------------------------------| +| `topic` | :fontawesome-solid-check: | A topic name. Multible topics can be added by separating them using `,` or `;` | ## `-` (unassign topics) -Unassigns the user the specified topics. +The `.-` command unassigns the user the specified topics. + +!!! important + Use only the topic name! Not the Path! ```css -.- +.- ``` Arguments: -| Argument | Required | Description | -|:--------:|:-------------------------:|:---------------------------------------------| -| `topics` | :fontawesome-solid-check: | One or more topics (separated by `,` or `;`) | +| Argument | Required | Description | +|:--------:|:-------------------------:|:----------------------------------------------------------------------------------| +| `topic` | :fontawesome-solid-check: | A topic name. Multible topics can be removed by separating them using `,` or `;`. | !!! hint You can use `.- *` to remove all topics at once. @@ -47,17 +60,27 @@ Arguments: ## `*` (register topics) -Adds new topics to the list of available topics. For each topic a new role will be created if there is no role with the same name yet (case insensitive). +The `.*` command adds new topics to the list of available topics. + +!!! note + You can use a topic's path! + +Topic Path Examples: + +- `Parent/Child` - Parent must already exist +- `TopLevelNode` +- `Main/Parent/Child2` - Main and Parent must already exist ```css -.* +.* ``` Arguments: -| Argument | Required | Description | -|:--------:|:-------------------------:|:---------------------------------------------| -| `topics` | :fontawesome-solid-check: | One or more topics (separated by `,` or `;`) | +| Argument | Required | Description | +|:------------:|:-------------------------:|:---------------------------------------------------------------------------------------------| +| `topic` | :fontawesome-solid-check: | The new topic's path. Multible topics can be registered by separating them using `,` or `;`. | +| `assignable` | | Asignability of the created topic/topics | Required Permissions: @@ -66,17 +89,20 @@ Required Permissions: ## `/` (delete topics) -Removes topics from the list of available topics and deletes the associated roles. +The `./` command removes topics from the list of available topics and deletes the associated roles. + +!!! important + Use only the topic name! Not the Path! ```css -./ +./ ``` Arguments: -| Argument | Required | Description | -|:--------:|:-------------------------:|:---------------------------------------------| -| `topics` | :fontawesome-solid-check: | One or more topics (separated by `,` or `;`) | +| Argument | Required | Description | +|:--------:|:-------------------------:|:----------------------------------------------------------------------------------| +| `topic` | :fontawesome-solid-check: | A topic name. Multible topics can be deleted by separating them using `,` or `;`. | Required Permissions: @@ -85,18 +111,132 @@ Required Permissions: ## `%` (unregister topics) -Unregisters topics without deleting the associated roles. +The `.%` command unregisters topics without deleting the associated roles. ```css -.% +.% ``` Arguments: | Argument | Required | Description | |:--------:|:-------------------------:|:---------------------------------------------| -| `topics` | :fontawesome-solid-check: | One or more topics (separated by `,` or `;`) | +| `topic` | :fontawesome-solid-check: | One or more topics (separated by `,` or `;`) | Required Permissions: - `betheprofessional.manage` + + +## `topic` + +The `.topic` command pings all members by topic name. +If a role exists for the topic, it'll ping the role. + +If `message` is set, the bot will reply to the given message. + +```css +.topic [message] +``` + +| Argument | Required | Description | +|:------------:|:-------------------------:|:---------------------------------------------------| +| `topic_name` | :fontawesome-solid-check: | A topic name. | +| `message` | | A Discord Message. e.g. Message ID or Message Link | + + +## `btp` + +The `.btp` command shows all BTP Settings. +It requires the `betheprofessional.read` Permission. + + +### `leaderboard` + +The `.btp leaderboard` command lists the top `n` topics sorted by users. + +```css +.btp [leaderboard|lb] [n] +``` + +| Argument | Required | Description | +|:-----------:|:--------:|:-----------------------------------------------------------------------------------------------------------------------------------------------| +| `n` | | Number of topics shown in the leaderboard. Limited by a Setting. Permission to bypass the Limit `betheprofessional.bypass_leaderboard_n_limit` | +| `use_cache` | | Disable Cache. Requires the Bypass Permission `betheprofessional.bypass_leaderboard_cache` | + + +### `role_limit` + +The `.btp role_limit` command is used to change the `role_limit` Settings for BTP. + +```css +.btp role_limit +``` + +| Argument | Required | Description | +|:------------:|:-------------------------:|:----------------------------| +| `role_limit` | :fontawesome-solid-check: | New value of `role_setting` | + + +### `role_create_min_users` + +The `.btp role_create_min_users` command is used to change the `role_create_min_users` Settings for BTP. + +```css +.btp role_create_min_users +``` + +| Argument | Required | Description | +|:-----------------------:|:-------------------------:|:-------------------------------------| +| `role_create_min_users` | :fontawesome-solid-check: | New value of `role_create_min_users` | + + +### `leaderboard_default_n` + +The `.btp leaderboard_default_n` command is used to change the `leaderboard_default_n` Settings for BTP. + +```css +.btp leaderboard_default_n +``` + +| Argument | Required | Description | +|:-----------------------:|:-------------------------:|:-------------------------------------| +| `leaderboard_default_n` | :fontawesome-solid-check: | New value of `leaderboard_default_n` | + + +### `leaderboard_max_n` + +The `.btp leaderboard_max_n` command is used to change the `leaderboard_max_n` Settings for BTP. + +```css +.btp leaderboard_max_n +``` + +| Argument | Required | Description | +|:-------------------:|:-------------------------:|:---------------------------------| +| `leaderboard_max_n` | :fontawesome-solid-check: | New value of `leaderboard_max_n` | + + +## `user_topics` + +The `usertopics` command is used to show all topics a User has assigned. + +```css +.[usertopics|usertopic|utopics|utopic] [member] +``` + +| Argument | Required | Description | +|:--------:|:--------:|:------------------------------------------------------| +| `member` | | A member. Default is the Member executing the command | + + +## `topic_update_roles` + +The `.topic_update_roles` manually updates the Top Topics. +The Top Topics will get a Role. +These roles remain even in the case of a rejoin. +It will usually get executed in a 24-hour loop. + +```css +.[topic_update_roles|topic_update|update_roles] +``` diff --git a/general/betheprofessional/models.py b/general/betheprofessional/models.py index b7dbf7f47..e668e0c2b 100644 --- a/general/betheprofessional/models.py +++ b/general/betheprofessional/models.py @@ -1,17 +1,41 @@ -from typing import Union +from __future__ import annotations -from sqlalchemy import BigInteger, Column +from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Integer, String +from sqlalchemy.orm import backref, relationship from PyDrocsid.database import Base, db -class BTPRole(Base): - __tablename__ = "btp_role" +class BTPTopic(Base): + __tablename__ = "btp_topic" - role_id: Union[Column, int] = Column(BigInteger, primary_key=True, unique=True) + id: Column | int = Column(Integer, primary_key=True) + name: Column | str = Column(String(255), unique=True) + parent_id: Column | int = Column(Integer, ForeignKey("btp_topic.id", ondelete="CASCADE")) + children: list[BTPTopic] = relationship( + "BTPTopic", backref=backref("parent", remote_side=id, foreign_keys=[parent_id]), lazy="subquery" + ) + role_id: Column | int = Column(BigInteger, unique=True) + users: list[BTPUser] = relationship("BTPUser", back_populates="topic", lazy="subquery") + assignable: Column | bool = Column(Boolean) @staticmethod - async def create(role_id: int) -> "BTPRole": - row = BTPRole(role_id=role_id) + async def create(name: str, role_id: int | None, assignable: bool, parent_id: int | None) -> BTPTopic: + row = BTPTopic(name=name, role_id=role_id, parent_id=parent_id, assignable=assignable) + await db.add(row) + return row + + +class BTPUser(Base): + __tablename__ = "btp_users" + + id: Column | int = Column(Integer, primary_key=True) + user_id: Column | int = Column(BigInteger) + topic_id: Column | int = Column(Integer, ForeignKey("btp_topic.id", ondelete="CASCADE")) + topic: BTPTopic = relationship("BTPTopic", back_populates="users", lazy="subquery", foreign_keys=[topic_id]) + + @staticmethod + async def create(user_id: int, topic_id: int) -> BTPUser: + row = BTPUser(user_id=user_id, topic_id=topic_id) await db.add(row) return row diff --git a/general/betheprofessional/permissions.py b/general/betheprofessional/permissions.py index 2889a1a47..605dbae52 100644 --- a/general/betheprofessional/permissions.py +++ b/general/betheprofessional/permissions.py @@ -10,3 +10,6 @@ def description(self) -> str: return t.betheprofessional.permissions[self.name] manage = auto() + read = auto() + bypass_leaderboard_cache = auto() + bypass_leaderboard_n_limit = auto() diff --git a/general/betheprofessional/settings.py b/general/betheprofessional/settings.py new file mode 100644 index 000000000..3222c6894 --- /dev/null +++ b/general/betheprofessional/settings.py @@ -0,0 +1,10 @@ +from PyDrocsid.settings import Settings + + +class BeTheProfessionalSettings(Settings): + # TODO add comments to explain the settings + RoleLimit = 100 + RoleCreateMinUsers = 1 # TODO + + LeaderboardDefaultN = 10 + LeaderboardMaxN = 20 diff --git a/general/betheprofessional/translations/en.yml b/general/betheprofessional/translations/en.yml index 0dd724296..84e08831b 100644 --- a/general/betheprofessional/translations/en.yml +++ b/general/betheprofessional/translations/en.yml @@ -1,6 +1,9 @@ permissions: - manage: manage betheprofessional roles - + manage: manage betheprofessional + read: read betheprofessional settings + bypass_leaderboard_cache: bypass leadboard cache + bypass_leaderboard_n_limit: bypass leaderboard n limit +missing_cache_bypass_permission: "Missing Cache bypass Permission" # betheprofessional betheprofessional: BeTheProfessional youre_not_the_first_one: "Topic `{}` not found.\nYou're not the first one to try this, {}" @@ -23,8 +26,6 @@ topic_invalid_chars: Topic name `{}` contains invalid characters. topic_too_long: Topic name `{}` is too long. topic_already_registered: Topic `{}` has already been registered. topic_not_registered: Topic `{}` has not been registered. -topic_not_registered_too_high: Topic could not be registered because `@{}` is higher than `@{}`. -topic_not_registered_managed_role: Topic could not be registered because `@{}` cannot be assigned manually. topics_registered: one: "Topic has been registered successfully. :white_check_mark:" @@ -34,8 +35,49 @@ log_topics_registered: many: "{cnt} **topics** have been **registered**: {topics}" topics_unregistered: - one: "Topic has been deleted successfully. :white_check_mark:" - many: "{cnt} topics have been deleted successfully. :white_check_mark:" + one: "Topic has been deleted successfully. :white_check_mark:\nAll child Topics have been removed as well." + many: "{cnt} topics have been deleted successfully. :white_check_mark:\nAll child Topics have been removed as well." +confirm_delete_topics: "Are you sure you want to delete the following topics?\n{topics}\n:warning: This will delete all child Topics as well. :warning:" log_topics_unregistered: - one: "The **topic** {topics} has been **removed**." - many: "{cnt} **topics** have been **removed**: {topics}" + one: "The **topic** {topics} has been **removed**.\nAll child Topics have been removed as well." + many: "{cnt} **topics** have been **removed**: {topics}\nAll child Topics have been removed as well." + +single_un_assign_help: "Hey, did you know, that you can assign multiple topics by using `.+ TOPIC1,TOPIC2,TOPIC3` and remove multiple topics the same way using `.-`?\nTo remove all your Topics you can use `.- *`" + +user_topics: + zero: "{} has no topics assigned" + one: "{} has assigned the following topic: {}" + many: "{} has assigned the following topics: {}" + +parent_not_exists: "Parent `{}` doesn't exists" +parent_format_help: "Please write `[Parents/]Topic-Name`" +group_not_parent_group: "The group `{}` is not the same as the group of the Parent `{}`" +nobody_has_topic: "Nobody has the Topic `{}`" + +leaderboard_n_too_big: "The given `N={}` is bigger than the maximum `N={}`" +leaderboard_default_n_bigger_than_max_n: "The default N is bigger than the maximum N" +leaderboard_configuration_error: "Internal Configuration Error" +leaderboard_n_zero_error: "N cant be zero or less!" + +fetching_topic_role_failed: "Failed to fetch Role of the Topic `{}` with the Role ID `{}`" + +leaderboard_colmn_name: "[NAME]" +leaderboard_colmn_users: "[USERS]" +leaderboard_title: "Top `{}` - Most assigned Topics" + +must_be_above_zero: "`{}` must be above zero!" +must_be_zero_or_above: "`{}` must be zero or above!" + +settings: + RoleLimit: + name: "Role Limit" + updated: "The BTP Role Limit is now `{}`" + RoleCreateMinUsers: + name: "Role Create Min Users" + updated: "Role Create Min Users Limit is now `{}`" + LeaderboardDefaultN: + name: "Leaderboard Default N" + updated: "Leaderboard Default N is now `{}`" + LeaderboardMaxN: + name: "Leaderboard Max N" + updated: "Leaderboard Max N is now `{}`"