From b9c5c6ab10934bdf7fd6c8240e1bb3b976df833b Mon Sep 17 00:00:00 2001 From: stijndcl Date: Wed, 13 Jul 2022 22:54:16 +0200 Subject: [PATCH 1/5] Add flake8 docstring plugin, formatting, create base class for embeds & models --- .flake8 | 25 ++++++++- database/crud/ufora_announcements.py | 1 + database/crud/users.py | 3 +- didier/cogs/help.py | 5 +- didier/cogs/other.py | 23 ++++++++ didier/cogs/owner.py | 5 +- didier/data/apis/__init__.py | 0 didier/data/apis/urban_dictionary.py | 14 +++++ didier/data/embeds/base.py | 22 ++++++++ didier/data/embeds/ufora/announcements.py | 3 +- didier/data/embeds/urban_dictionary.py | 24 ++++++++ didier/data/modals/custom_commands.py | 8 ++- didier/didier.py | 7 ++- didier/exceptions/__init__.py | 0 didier/exceptions/config.py | 12 ++++ didier/utils/discord/colours.py | 7 +++ didier/utils/discord/converters/numbers.py | 1 + didier/utils/discord/prefix.py | 1 + didier/utils/types/string.py | 1 + pyproject.toml | 1 + requirements-dev.txt | 2 +- requirements.txt | 3 + settings.py | 33 +++++++++-- .../test_data/urban_dictionary_response.json | 56 +++++++++++++++++++ 24 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 didier/cogs/other.py create mode 100644 didier/data/apis/__init__.py create mode 100644 didier/data/apis/urban_dictionary.py create mode 100644 didier/data/embeds/base.py create mode 100644 didier/data/embeds/urban_dictionary.py create mode 100644 didier/exceptions/__init__.py create mode 100644 didier/exceptions/config.py create mode 100644 didier/utils/discord/colours.py create mode 100644 tests/test_data/urban_dictionary_response.json diff --git a/.flake8 b/.flake8 index 3b71da3..259dc4e 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,5 @@ [flake8] +# Don't lint non-Python files exclude = .git, .github, @@ -9,10 +10,30 @@ exclude = htmlcov, tests, venv -ignore=E203 +# Disable rules that we don't care about (or conflict with others) +extend-ignore = + # Missing docstring in public module + D100, D104, + # Missing docstring in magic method + D105, + # Missing docstring in __init__ + D107, + # First line of docstrings should end with a period + D400, + # First line of docstrings should be in imperative mood + D401, + # Whitespace before ":" + E203, +# Don't require docstrings when overriding a method, +# the base method should have a docstring but the rest not +ignore-decorator=overrides max-line-length = 120 +# Disable some rules for entire files per-file-ignores = # Missing __all__, main isn't supposed to be imported main.py: DALL000, # Missing __all__, Cogs aren't modules - ./didier/cogs/*: DALL000 \ No newline at end of file + ./didier/cogs/*: DALL000, + # All of the colours methods are just oneliners to create a colour, + # there's no point adding docstrings (function names are enough) + ./didier/utils/discord/colours.py: D103 diff --git a/database/crud/ufora_announcements.py b/database/crud/ufora_announcements.py index 5f82390..48a06ae 100644 --- a/database/crud/ufora_announcements.py +++ b/database/crud/ufora_announcements.py @@ -28,6 +28,7 @@ async def create_new_announcement( async def remove_old_announcements(session: AsyncSession): """Delete all announcements that are > 8 days old + The RSS feed only goes back 7 days, so all of these old announcements never have to be checked again when checking if an announcement is fresh or not. """ diff --git a/database/crud/users.py b/database/crud/users.py index cc5a899..57c5029 100644 --- a/database/crud/users.py +++ b/database/crud/users.py @@ -12,7 +12,8 @@ __all__ = [ async def get_or_add(session: AsyncSession, user_id: int) -> User: """Get a user's profile - If it doesn't exist yet, create it (along with all linked datastructures). + + If it doesn't exist yet, create it (along with all linked datastructures) """ statement = select(User).where(User.user_id == user_id) user: Optional[User] = (await session.execute(statement)).scalar_one_or_none() diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 33250a8..77ae0f1 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -2,13 +2,15 @@ from typing import List, Mapping, Optional import discord from discord.ext import commands +from overrides import overrides from didier import Didier class CustomHelpCommand(commands.MinimalHelpCommand): """Customised Help command to override the default implementation - The default is ugly as hell + + The default is ugly as hell so we do some fiddling with it """ def _help_embed_base(self, title: str) -> discord.Embed: @@ -30,6 +32,7 @@ class CustomHelpCommand(commands.MinimalHelpCommand): return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name)) + @overrides async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], List[commands.Command]], /): embed = self._help_embed_base("Categorieën") filtered_cogs = await self._filter_cogs(list(mapping.keys())) diff --git a/didier/cogs/other.py b/didier/cogs/other.py new file mode 100644 index 0000000..93098ac --- /dev/null +++ b/didier/cogs/other.py @@ -0,0 +1,23 @@ +from discord.ext import commands + +from didier import Didier +from didier.data.apis import urban_dictionary + + +class Other(commands.Cog): + """Cog for commands that don't really belong anywhere else""" + + client: Didier + + def __init__(self, client: Didier): + self.client = client + + @commands.command(name="Define", aliases=["Ud", "Urban"], usage="[Woord]") + async def define(self, ctx: commands.Context, *, query: str): + """Look up the definition of a word on the Urban Dictionary""" + definitions = urban_dictionary.lookup(self.client.http_session, query) + + +async def setup(client: Didier): + """Load the cog""" + await client.add_cog(Other(client)) diff --git a/didier/cogs/owner.py b/didier/cogs/owner.py index 401067c..ca72cd6 100644 --- a/didier/cogs/owner.py +++ b/didier/cogs/owner.py @@ -25,8 +25,9 @@ class Owner(commands.Cog): self.client = client async def cog_check(self, ctx: commands.Context) -> bool: - """Global check for every command in this cog, so we don't have to add - is_owner() to every single command separately + """Global check for every command in this cog + + This means that we don't have to add is_owner() to every single command separately """ # pylint: disable=W0236 # Pylint thinks this can't be async, but it can return await self.client.is_owner(ctx.author) diff --git a/didier/data/apis/__init__.py b/didier/data/apis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/didier/data/apis/urban_dictionary.py b/didier/data/apis/urban_dictionary.py new file mode 100644 index 0000000..ac42b9c --- /dev/null +++ b/didier/data/apis/urban_dictionary.py @@ -0,0 +1,14 @@ +from aiohttp import ClientSession + +from didier.data.embeds.urban_dictionary import Definition + +__all__ = ["lookup"] + + +async def lookup(http_session: ClientSession, query: str) -> list[Definition]: + """Fetch the Urban Dictionary definitions for a given word""" + url = "https://api.urbandictionary.com/v0/define" + + async with http_session.get(url, params={"term": query}) as response: + response_json = await response.json() + return list(map(Definition.parse_obj, response_json["list"])) diff --git a/didier/data/embeds/base.py b/didier/data/embeds/base.py new file mode 100644 index 0000000..38d7c61 --- /dev/null +++ b/didier/data/embeds/base.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod + +import discord +from pydantic import BaseModel + +__all__ = [ + "EmbedBaseModel", + "EmbedPydantic", +] + + +class EmbedBaseModel(ABC): + """Abstract base class for a model that can be turned into a Discord embed""" + + @abstractmethod + def to_embed(self) -> discord.Embed: + """Turn this model into a Discord embed""" + raise NotImplementedError + + +class EmbedPydantic(ABC, EmbedBaseModel, BaseModel): + """Pydantic version of EmbedModel""" diff --git a/didier/data/embeds/ufora/announcements.py b/didier/data/embeds/ufora/announcements.py index 984674e..f4a8bdd 100644 --- a/didier/data/embeds/ufora/announcements.py +++ b/didier/data/embeds/ufora/announcements.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession import settings from database.crud import ufora_announcements as crud from database.models import UforaCourse +from didier.data.embeds.base import EmbedBaseModel from didier.utils.types.datetime import int_to_weekday from didier.utils.types.string import leading @@ -25,7 +26,7 @@ __all__ = [ @dataclass -class UforaNotification: +class UforaNotification(EmbedBaseModel): """A single notification from Ufora""" content: dict diff --git a/didier/data/embeds/urban_dictionary.py b/didier/data/embeds/urban_dictionary.py new file mode 100644 index 0000000..99a9a92 --- /dev/null +++ b/didier/data/embeds/urban_dictionary.py @@ -0,0 +1,24 @@ +from datetime import datetime + +import discord +from overrides import overrides + +from didier.data.embeds.base import EmbedPydantic + +__all__ = ["Definition"] + + +class Definition(EmbedPydantic): + """A definition from the Urban Dictionary""" + + word: str + definition: str + permalink: str + author: str + thumbs_up: int + thumbs_down: int + written_on: datetime + + @overrides + def to_embed(self) -> discord.Embed: + embed = discord.Embed() diff --git a/didier/data/modals/custom_commands.py b/didier/data/modals/custom_commands.py index 65c259f..35ac158 100644 --- a/didier/data/modals/custom_commands.py +++ b/didier/data/modals/custom_commands.py @@ -2,6 +2,7 @@ import traceback import typing import discord +from overrides import overrides from database.crud.custom_commands import create_command, edit_command from didier import Didier @@ -24,12 +25,14 @@ class CreateCustomCommand(discord.ui.Modal, title="Create Custom Command"): super().__init__(*args, **kwargs) self.client = client + @overrides async def on_submit(self, interaction: discord.Interaction): async with self.client.db_session as session: command = await create_command(session, str(self.name.value), str(self.response.value)) await interaction.response.send_message(f"Successfully created ``{command.name}``.", ephemeral=True) + @overrides async def on_error(self, interaction: discord.Interaction, error: Exception): # type: ignore await interaction.response.send_message("Something went wrong.", ephemeral=True) traceback.print_tb(error.__traceback__) @@ -37,7 +40,8 @@ class CreateCustomCommand(discord.ui.Modal, title="Create Custom Command"): class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"): """Modal to edit an existing custom command - Fills in the current values as defaults + + Fills in the current values as defaults for QOL """ name: discord.ui.TextInput @@ -59,6 +63,7 @@ class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"): ) ) + @overrides async def on_submit(self, interaction: discord.Interaction): name_field = typing.cast(discord.ui.TextInput, self.children[0]) response_field = typing.cast(discord.ui.TextInput, self.children[1]) @@ -68,6 +73,7 @@ class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"): await interaction.response.send_message(f"Successfully edited ``{self.original_name}``.", ephemeral=True) + @overrides async def on_error(self, interaction: discord.Interaction, error: Exception): # type: ignore await interaction.response.send_message("Something went wrong.", ephemeral=True) traceback.print_tb(error.__traceback__) diff --git a/didier/didier.py b/didier/didier.py index 14a9943..fbe855b 100644 --- a/didier/didier.py +++ b/didier/didier.py @@ -42,7 +42,10 @@ class Didier(commands.Bot): return DBSession() async def setup_hook(self) -> None: - """Hook called once the bot is initialised""" + """Do some initial setup + + This hook is called once the bot is initialised + """ # Load extensions await self._load_initial_extensions() await self._load_directory_extensions("didier/cogs") @@ -113,7 +116,9 @@ class Didier(commands.Bot): async def _try_invoke_custom_command(self, message: discord.Message) -> bool: """Check if the message tries to invoke a custom command + If it does, send the reply associated with it + Returns a boolean indicating if a message invoked a command or not """ # Doesn't start with the custom command prefix if not message.content.startswith(settings.DISCORD_CUSTOM_COMMAND_PREFIX): diff --git a/didier/exceptions/__init__.py b/didier/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/didier/exceptions/config.py b/didier/exceptions/config.py new file mode 100644 index 0000000..df73f7e --- /dev/null +++ b/didier/exceptions/config.py @@ -0,0 +1,12 @@ +__all__ = ["MissingEnvironmentVariable"] + + +class MissingEnvironmentVariable(RuntimeError): + """Exception raised when an environment variable is missing + + These are not necessarily checked on startup, because they may be unused + during a given test run, and random unrelated crashes would be annoying + """ + + def __init__(self, variable: str): + super().__init__(f"Missing environment variable: {variable}") diff --git a/didier/utils/discord/colours.py b/didier/utils/discord/colours.py new file mode 100644 index 0000000..ffcd8eb --- /dev/null +++ b/didier/utils/discord/colours.py @@ -0,0 +1,7 @@ +import discord + +__all__ = ["urban_dictionary_green"] + + +def urban_dictionary_green() -> discord.Colour: + return discord.Colour.from_rgb(220, 255, 0) diff --git a/didier/utils/discord/converters/numbers.py b/didier/utils/discord/converters/numbers.py index 573652f..61d991a 100644 --- a/didier/utils/discord/converters/numbers.py +++ b/didier/utils/discord/converters/numbers.py @@ -6,6 +6,7 @@ __all__ = ["abbreviated_number"] def abbreviated_number(argument: str) -> Union[str, int]: """Custom converter to allow numbers to be abbreviated + Examples: 515k 4m diff --git a/didier/utils/discord/prefix.py b/didier/utils/discord/prefix.py index f57e06d..df62ad4 100644 --- a/didier/utils/discord/prefix.py +++ b/didier/utils/discord/prefix.py @@ -10,6 +10,7 @@ __all__ = ["get_prefix"] def get_prefix(client: commands.Bot, message: Message) -> str: """Match a prefix against a message + This is done dynamically to allow variable amounts of whitespace, and through regexes to allow case-insensitivity among other things. """ diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index ab4f415..3f383a6 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -6,6 +6,7 @@ __all__ = ["leading", "pluralize"] def leading(character: str, string: str, target_length: Optional[int] = 2) -> str: """Add a leading [character] to [string] to make it length [target_length] + Pass None to target length to always do it (once), no matter the length """ # Cast to string just in case diff --git a/pyproject.toml b/pyproject.toml index ba0c283..a5ac198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ profile = "black" [tool.mypy] plugins = [ + "pydantic.mypy", "sqlalchemy.ext.mypy.plugin" ] [[tool.mypy.overrides]] diff --git a/requirements-dev.txt b/requirements-dev.txt index 6c6e38e..533740c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,8 +11,8 @@ types-pytz==2021.3.8 # Flake8 + plugins flake8==4.0.1 flake8-bandit==3.0.0 -flake8-black==0.3.3 flake8-bugbear==22.7.1 +flake8-docstrings==1.6.0 flake8-dunder-all==0.2.1 flake8-eradicate==1.2.1 flake8-isort==4.1.1 diff --git a/requirements.txt b/requirements.txt index 1b4240a..0737e1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,8 @@ git+https://github.com/Rapptz/discord.py environs==9.5.0 feedparser==6.0.10 markdownify==0.11.2 +overrides==6.1.0 +pydantic==1.9.1 +python-dateutil==2.8.2 pytz==2022.1 sqlalchemy[asyncio]==1.4.37 diff --git a/settings.py b/settings.py index d850eb8..bc35ab4 100644 --- a/settings.py +++ b/settings.py @@ -6,6 +6,26 @@ from environs import Env env = Env() env.read_env() +__all__ = [ + "SANDBOX", + "LOGFILE", + "DB_NAME", + "DB_USERNAME", + "DB_PASSWORD", + "DB_HOST", + "DB_PORT", + "DISCORD_TOKEN", + "DISCORD_READY_MESSAGE", + "DISCORD_STATUS_MESSAGE", + "DISCORD_TEST_GUILDS", + "DISCORD_BOOS_REACT", + "DISCORD_CUSTOM_COMMAND_PREFIX", + "UFORA_ANNOUNCEMENTS_CHANNEL", + "UFORA_RSS_TOKEN", + "URBAN_DICTIONARY_TOKEN", +] + + """General config""" SANDBOX: bool = env.bool("SANDBOX", True) LOGFILE: str = env.str("LOGFILE", "didier.log") @@ -18,13 +38,14 @@ DB_HOST: str = env.str("DB_HOST", "localhost") DB_PORT: int = env.int("DB_PORT", "5432") """Discord""" -DISCORD_TOKEN: str = env.str("DISC_TOKEN") -DISCORD_READY_MESSAGE: str = env.str("DISC_READY_MESSAGE", "I'M READY I'M READY I'M READY") -DISCORD_STATUS_MESSAGE: str = env.str("DISC_STATUS_MESSAGE", "with your Didier Dinks.") -DISCORD_TEST_GUILDS: list[int] = env.list("DISC_TEST_GUILDS", [], subcast=int) -DISCORD_BOOS_REACT: str = env.str("DISC_BOOS_REACT", "<:boos:629603785840263179>") -DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISC_CUSTOM_COMMAND_PREFIX", "?") +DISCORD_TOKEN: str = env.str("DISCORD_TOKEN") +DISCORD_READY_MESSAGE: str = env.str("DISCORD_READY_MESSAGE", "I'M READY I'M READY I'M READY") +DISCORD_STATUS_MESSAGE: str = env.str("DISCORD_STATUS_MESSAGE", "with your Didier Dinks.") +DISCORD_TEST_GUILDS: list[int] = env.list("DISCORD_TEST_GUILDS", [], subcast=int) +DISCORD_BOOS_REACT: str = env.str("DISCORD_BOOS_REACT", "<:boos:629603785840263179>") +DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISCORD_CUSTOM_COMMAND_PREFIX", "?") UFORA_ANNOUNCEMENTS_CHANNEL: Optional[int] = env.int("UFORA_ANNOUNCEMENTS_CHANNEL", None) """API Keys""" UFORA_RSS_TOKEN: Optional[str] = env.str("UFORA_RSS_TOKEN", None) +URBAN_DICTIONARY_TOKEN: Optional[str] = env.str("URBAN_DICTIONARY_TOKEN", None) diff --git a/tests/test_data/urban_dictionary_response.json b/tests/test_data/urban_dictionary_response.json new file mode 100644 index 0000000..04cedd5 --- /dev/null +++ b/tests/test_data/urban_dictionary_response.json @@ -0,0 +1,56 @@ +{ + "list": [ + { + "definition": "When you fall [asleep] [tweeting] about [nonsensical] things", + "permalink": "http://cofveve.urbanup.com/11642742", + "thumbs_up": 170, + "sound_urls": [], + "author": "Bonafidé", + "word": "cofveve", + "defid": 11642742, + "current_vote": "", + "written_on": "2017-06-03T00:32:10.987Z", + "example": "[Despite] [the negative] [press] cofveve", + "thumbs_down": 30 + }, + { + "definition": "when you want to type [conference] and your hands are too small to reach [the keys] (someone else's [brill] def)", + "permalink": "http://cofveve.urbanup.com/11662158", + "thumbs_up": 69, + "sound_urls": [], + "author": "blacklist2017", + "word": "cofveve", + "defid": 11662158, + "current_vote": "", + "written_on": "2017-06-07T00:44:36.793Z", + "example": "\"[i want] to [shut down] this [press] cofveve...", + "thumbs_down": 16 + }, + { + "definition": "[Bullshit]", + "permalink": "http://cofveve.urbanup.com/12386593", + "thumbs_up": 5, + "sound_urls": [], + "author": "FreedomTodd", + "word": "Cofveve", + "defid": 12386593, + "current_vote": "", + "written_on": "2018-01-06T17:52:59.822Z", + "example": "[I’m] [full of] [cofveve]", + "thumbs_down": 6 + }, + { + "definition": "When you dip [an apple] in a chocolate [Starbucks] [frappe]", + "permalink": "http://razzle-cofveve.urbanup.com/12006856", + "thumbs_up": 0, + "sound_urls": [], + "author": "harry potter theme song si ", + "word": "razzle cofveve", + "defid": 12006856, + "current_vote": "", + "written_on": "2017-09-30T03:00:54.233Z", + "example": "[My friend] loves [apples] and [Starbucks], so the razzle cofveve was perfect!", + "thumbs_down": 0 + } + ] +} \ No newline at end of file From c8392342a66b6328b7dfee0b42ce1b10c85922ec Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 14 Jul 2022 20:28:45 +0200 Subject: [PATCH 2/5] Urban dictionary commands --- .github/workflows/python.yml | 2 + codecov.yaml | 2 +- didier/cogs/other.py | 5 +- didier/data/apis/urban_dictionary.py | 5 +- didier/data/embeds/base.py | 2 +- didier/data/embeds/urban_dictionary.py | 40 ++++- didier/utils/discord/constants.py | 17 ++ didier/utils/discord/menus/__init__.py | 0 didier/utils/types/string.py | 14 +- pyproject.toml | 3 +- readme.md | 2 + .../test_data/urban_dictionary_response.json | 150 +++++++++++++----- 12 files changed, 198 insertions(+), 44 deletions(-) create mode 100644 didier/utils/discord/constants.py create mode 100644 didier/utils/discord/menus/__init__.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index b083e02..75e7e92 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -59,6 +59,8 @@ jobs: coverage xml - name: Upload coverage report to CodeCov uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV }} linting: needs: [dependencies] runs-on: ubuntu-latest diff --git a/codecov.yaml b/codecov.yaml index 6ce17c3..e916d57 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -10,5 +10,5 @@ coverage: precision: 5 ignore: - - "./tests/*" - "./didier/cogs/*" # Cogs can't really be tested properly + - "./tests/*" diff --git a/didier/cogs/other.py b/didier/cogs/other.py index 93098ac..6a036e2 100644 --- a/didier/cogs/other.py +++ b/didier/cogs/other.py @@ -12,10 +12,11 @@ class Other(commands.Cog): def __init__(self, client: Didier): self.client = client - @commands.command(name="Define", aliases=["Ud", "Urban"], usage="[Woord]") + @commands.hybrid_command(name="define", description="Urban Dictionary", aliases=["Ud", "Urban"], usage="[Woord]") async def define(self, ctx: commands.Context, *, query: str): """Look up the definition of a word on the Urban Dictionary""" - definitions = urban_dictionary.lookup(self.client.http_session, query) + definitions = await urban_dictionary.lookup(self.client.http_session, query) + await ctx.reply(embed=definitions[0].to_embed(), mention_author=False) async def setup(client: Didier): diff --git a/didier/data/apis/urban_dictionary.py b/didier/data/apis/urban_dictionary.py index ac42b9c..40381ea 100644 --- a/didier/data/apis/urban_dictionary.py +++ b/didier/data/apis/urban_dictionary.py @@ -2,7 +2,10 @@ from aiohttp import ClientSession from didier.data.embeds.urban_dictionary import Definition -__all__ = ["lookup"] +__all__ = ["lookup", "PER_PAGE"] + + +PER_PAGE = 10 async def lookup(http_session: ClientSession, query: str) -> list[Definition]: diff --git a/didier/data/embeds/base.py b/didier/data/embeds/base.py index 38d7c61..8cab519 100644 --- a/didier/data/embeds/base.py +++ b/didier/data/embeds/base.py @@ -18,5 +18,5 @@ class EmbedBaseModel(ABC): raise NotImplementedError -class EmbedPydantic(ABC, EmbedBaseModel, BaseModel): +class EmbedPydantic(EmbedBaseModel, BaseModel, ABC): """Pydantic version of EmbedModel""" diff --git a/didier/data/embeds/urban_dictionary.py b/didier/data/embeds/urban_dictionary.py index 99a9a92..14086bb 100644 --- a/didier/data/embeds/urban_dictionary.py +++ b/didier/data/embeds/urban_dictionary.py @@ -2,8 +2,12 @@ from datetime import datetime import discord from overrides import overrides +from pydantic import validator from didier.data.embeds.base import EmbedPydantic +from didier.utils.discord import colours +from didier.utils.discord.constants import Limits +from didier.utils.types import string as string_utils __all__ = ["Definition"] @@ -13,12 +17,46 @@ class Definition(EmbedPydantic): word: str definition: str + example: str permalink: str author: str thumbs_up: int thumbs_down: int written_on: datetime + @property + def ratio(self) -> float: + """The up vote/down vote ratio + + This ratio is rounded down to 2 decimal places + If the amount of down votes is 0, always return 100% + """ + # No down votes, possibly no up votes either + # Avoid a 0/0 situation + if self.thumbs_down == 0: + return 100 + + total_votes = self.thumbs_up + self.thumbs_down + return round(100 * self.thumbs_up / total_votes, 2) + + @validator("definition", "example") + def modify_long_text(cls, field): + """Remove brackets from fields & cut them off if they are too long""" + field = field.replace("[", "").replace("]", "") + return string_utils.abbreviate(field, max_length=Limits.EMBED_FIELD_VALUE_LENGTH) + @overrides def to_embed(self) -> discord.Embed: - embed = discord.Embed() + embed = discord.Embed(colour=colours.urban_dictionary_green()) + embed.set_author(name="Urban Dictionary") + + embed.add_field(name="Woord", value=self.word, inline=True) + embed.add_field(name="Auteur", value=self.author, inline=True) + embed.add_field(name="Definitie", value=self.definition, inline=False) + embed.add_field(name="Voorbeeld", value=self.example or "\u200B", inline=False) + embed.add_field( + name="Rating", value=f"{self.ratio}% ({self.thumbs_up}/{self.thumbs_up + self.thumbs_down})", inline=True + ) + embed.add_field(name="Link", value=f"[Urban Dictionary]({self.permalink})", inline=True) + + return embed diff --git a/didier/utils/discord/constants.py b/didier/utils/discord/constants.py new file mode 100644 index 0000000..707d635 --- /dev/null +++ b/didier/utils/discord/constants.py @@ -0,0 +1,17 @@ +from enum import Enum + +__all__ = ["Limits"] + + +class Limits(int, Enum): + """Enum for the limits of certain fields""" + + EMBED_AUTHOR_LENGTH = 256 + EMBED_DESCRIPTION_LENGTH = 4096 + EMBED_FIELD_COUNT = 25 + EMBED_FIELD_NAME_LENGTH = 256 + EMBED_FIELD_VALUE_LENGTH = 1024 + EMBED_FOOTER_LENGTH = 2048 + EMBED_TITLE_LENGTH = 256 + EMBED_TOTAL_LENGTH = 6000 + MESSAGE_LENGTH = 2000 diff --git a/didier/utils/discord/menus/__init__.py b/didier/utils/discord/menus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index 3f383a6..015996a 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -1,7 +1,19 @@ import math from typing import Optional -__all__ = ["leading", "pluralize"] +__all__ = ["abbreviate", "leading", "pluralize"] + + +def abbreviate(text: str, max_length: int) -> str: + """Abbreviate a string to a maximum length + + If the string is longer, add an ellipsis (...) at the end + """ + if len(text) <= max_length: + return text + + # Strip to avoid ending on random double newlines + return text[: max_length - 1].strip() + "…" def leading(character: str, string: str, target_length: Optional[int] = 2) -> str: diff --git a/pyproject.toml b/pyproject.toml index a5ac198..2a28e94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ omit = [ "./database/migrations.py", "./didier/cogs/*", "./didier/didier.py", - "./didier/data/*" + "./didier/data/*", + "./didier/utils/discord/colours.py" ] [tool.isort] diff --git a/readme.md b/readme.md index 2c2e678..58e55e9 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # Didier +[![wakatime](https://wakatime.com/badge/user/3543d4ec-ec93-4b43-abd6-2bc2e310f3c4/project/100156e4-2fb5-40b4-b808-e47ef687905c.svg)](https://wakatime.com/badge/user/3543d4ec-ec93-4b43-abd6-2bc2e310f3c4/project/100156e4-2fb5-40b4-b808-e47ef687905c) + You bet. The time has come. ### Discord Documentation diff --git a/tests/test_data/urban_dictionary_response.json b/tests/test_data/urban_dictionary_response.json index 04cedd5..071a880 100644 --- a/tests/test_data/urban_dictionary_response.json +++ b/tests/test_data/urban_dictionary_response.json @@ -1,56 +1,134 @@ { "list": [ { - "definition": "When you fall [asleep] [tweeting] about [nonsensical] things", - "permalink": "http://cofveve.urbanup.com/11642742", - "thumbs_up": 170, + "definition": "It literally means covfefe.\n\nOriginated from [Donald Trump's] [tweet]: \"Despite the constant [negative press] covfefe\"", + "permalink": "http://covfefe.urbanup.com/11630874", + "thumbs_up": 14701, "sound_urls": [], - "author": "Bonafidé", - "word": "cofveve", - "defid": 11642742, + "author": "lightinglax", + "word": "covfefe", + "defid": 11630874, "current_vote": "", - "written_on": "2017-06-03T00:32:10.987Z", - "example": "[Despite] [the negative] [press] cofveve", - "thumbs_down": 30 + "written_on": "2017-05-31T04:54:39.112Z", + "example": "\"[It's time] to [nuke this] place down.\" \"What's [the code]?\" \"covfefe.\"", + "thumbs_down": 2337 }, { - "definition": "when you want to type [conference] and your hands are too small to reach [the keys] (someone else's [brill] def)", - "permalink": "http://cofveve.urbanup.com/11662158", - "thumbs_up": 69, + "definition": "An [unpresidented] [typo]", + "permalink": "http://covfefe.urbanup.com/11631018", + "thumbs_up": 2295, "sound_urls": [], - "author": "blacklist2017", - "word": "cofveve", - "defid": 11662158, + "author": "John Wilkes Bluetooth", + "word": "covfefe", + "defid": 11631018, "current_vote": "", - "written_on": "2017-06-07T00:44:36.793Z", - "example": "\"[i want] to [shut down] this [press] cofveve...", - "thumbs_down": 16 + "written_on": "2017-05-31T05:21:10.475Z", + "example": "[Despite] the [constant] [negative press] covfefe", + "thumbs_down": 776 }, { - "definition": "[Bullshit]", - "permalink": "http://cofveve.urbanup.com/12386593", - "thumbs_up": 5, + "definition": "Originally coined by Donald Trump, [45th President of the United States] of America, covfefe will inevitably come to be syonymous with sending a text or [publishing] a tweet prematurely and with [egregious] spelling errors.", + "permalink": "http://covfefe.urbanup.com/11631031", + "thumbs_up": 3182, "sound_urls": [], - "author": "FreedomTodd", - "word": "Cofveve", - "defid": 12386593, + "author": "covfefe89", + "word": "covfefe", + "defid": 11631031, "current_vote": "", - "written_on": "2018-01-06T17:52:59.822Z", - "example": "[I’m] [full of] [cofveve]", - "thumbs_down": 6 + "written_on": "2017-05-31T05:23:13.478Z", + "example": "Damn dog, I just covfefed tryna ask Ashley out. I meant to text her to get a drink but instead i wrote \"despite the constant [negative press] [convfefe].\" She's gonna think I'm [mad stupid] now.", + "thumbs_down": 1325 }, { - "definition": "When you dip [an apple] in a chocolate [Starbucks] [frappe]", - "permalink": "http://razzle-cofveve.urbanup.com/12006856", - "thumbs_up": 0, + "definition": "¯\\_(ツ)_/¯", + "permalink": "http://covfefe.urbanup.com/11630896", + "thumbs_up": 245, "sound_urls": [], - "author": "harry potter theme song si ", - "word": "razzle cofveve", - "defid": 12006856, + "author": "zanzalaz", + "word": "covfefe", + "defid": 11630896, "current_vote": "", - "written_on": "2017-09-30T03:00:54.233Z", - "example": "[My friend] loves [apples] and [Starbucks], so the razzle cofveve was perfect!", - "thumbs_down": 0 + "written_on": "2017-05-31T04:56:45.441Z", + "example": "\"[Despite] the [constant] [negative press] covfefe\"", + "thumbs_down": 105 + }, + { + "definition": "The [nuclear codes].", + "permalink": "http://covfefe.urbanup.com/11630856", + "thumbs_up": 830, + "sound_urls": [], + "author": "gosty7", + "word": "covfefe", + "defid": 11630856, + "current_vote": "", + "written_on": "2017-05-31T04:52:01.695Z", + "example": "[Despite] the [constant] [negative press] covfefe", + "thumbs_down": 445 + }, + { + "definition": "No one really knows yet, but the President of the United States of America used it in a tweet so it must be a [bigly] important word. It must be [YUGE] somewhere as he knows [all the best words].", + "permalink": "http://covfefe.urbanup.com/11630902", + "thumbs_up": 2711, + "sound_urls": [], + "author": "CovfefeFan", + "word": "covfefe", + "defid": 11630902, + "current_vote": "", + "written_on": "2017-05-31T04:57:30.841Z", + "example": "[Despite] the [constant] [negative press] covfefe", + "thumbs_down": 1612 + }, + { + "definition": "an [alternative fact] for [the word] 'coverage'", + "permalink": "http://covfefe.urbanup.com/11631148", + "thumbs_up": 92, + "sound_urls": [], + "author": "ceckhardt", + "word": "covfefe", + "defid": 11631148, + "current_vote": "", + "written_on": "2017-05-31T05:51:21.446Z", + "example": "\"[Despite] [constant] [negative press] covfefe\"", + "thumbs_down": 52 + }, + { + "definition": "Acronym : descriptive [--] Can’t [Operate] Very Fucking Efficiently [Faking] Effectiveness", + "permalink": "http://covfefe.urbanup.com/11634131", + "thumbs_up": 40, + "sound_urls": [], + "author": "Robert the Punster", + "word": "covfefe", + "defid": 11634131, + "current_vote": "", + "written_on": "2017-05-31T21:36:36.704Z", + "example": "[Some people] covfefe", + "thumbs_down": 22 + }, + { + "definition": "A word [Trump] [tweeted] and [nobody knows] what the hell it means.", + "permalink": "http://covfefe.urbanup.com/11630891", + "thumbs_up": 105, + "sound_urls": [], + "author": "pmince", + "word": "covfefe", + "defid": 11630891, + "current_vote": "", + "written_on": "2017-05-31T04:56:21.018Z", + "example": "[Despite] the [constant] [negative press] covfefe", + "thumbs_down": 75 + }, + { + "definition": "(N) Word used to describe a person with so much [egotism] that he/she cannot [admit] he/she made even the simplest of [mistakes].", + "permalink": "http://covfefe.urbanup.com/11636070", + "thumbs_up": 28, + "sound_urls": [], + "author": "PJ the Coug", + "word": "covfefe", + "defid": 11636070, + "current_vote": "", + "written_on": "2017-06-01T06:33:01.986Z", + "example": "\"[Kevin] [fell asleep] playing online last night. He just said he was 'afk' for [four] hours. He's got a lot of covfefe.\"", + "thumbs_down": 17 } ] } \ No newline at end of file From 84bf1d7a26b716ed176fe9fa5be050037f8761c0 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 14 Jul 2022 22:03:30 +0200 Subject: [PATCH 3/5] Add crud functions to get ufora courses --- database/crud/ufora_courses.py | 30 ++++++++++++++++ tests/test_database/test_crud/conftest.py | 34 +++++++++++++++++++ .../test_crud/test_ufora_announcements.py | 18 ---------- .../test_crud/test_ufora_courses.py | 22 ++++++++++++ 4 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 database/crud/ufora_courses.py create mode 100644 tests/test_database/test_crud/conftest.py create mode 100644 tests/test_database/test_crud/test_ufora_courses.py diff --git a/database/crud/ufora_courses.py b/database/crud/ufora_courses.py new file mode 100644 index 0000000..152d687 --- /dev/null +++ b/database/crud/ufora_courses.py @@ -0,0 +1,30 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models import UforaCourse, UforaCourseAlias + + +async def get_all_courses(session: AsyncSession) -> list[UforaCourse]: + """Get a list of all courses in the database""" + statement = select(UforaCourse) + return (await session.execute(statement)).scalars().all() + + +async def get_course_by_name(session: AsyncSession, query: str) -> Optional[UforaCourse]: + """Try to find a course by its name + + This checks for regular name first, and then aliases + """ + # Search case-insensitively + query = query.lower() + + statement = select(UforaCourse).where(UforaCourse.name.ilike(f"%{query}%")) + result = (await session.execute(statement)).scalars().first() + if result: + return result + + statement = select(UforaCourseAlias).where(UforaCourseAlias.alias.ilike(f"%{query}%")) + result = (await session.execute(statement)).scalars().first() + return result.course if result else None diff --git a/tests/test_database/test_crud/conftest.py b/tests/test_database/test_crud/conftest.py new file mode 100644 index 0000000..be5f889 --- /dev/null +++ b/tests/test_database/test_crud/conftest.py @@ -0,0 +1,34 @@ +import datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models import UforaAnnouncement, UforaCourse, UforaCourseAlias + + +@pytest.fixture +async def course(database_session: AsyncSession) -> UforaCourse: + """Fixture to create a course""" + course = UforaCourse(name="test", code="code", year=1, log_announcements=True) + database_session.add(course) + await database_session.commit() + return course + + +@pytest.fixture +async def course_with_alias(database_session: AsyncSession, course: UforaCourse) -> UforaCourse: + """Fixture to create a course with an alias""" + alias = UforaCourseAlias(course_id=course.course_id, alias="alias") + database_session.add(alias) + await database_session.commit() + await database_session.refresh(course) + return course + + +@pytest.fixture +async def announcement(course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement: + """Fixture to create an announcement""" + announcement = UforaAnnouncement(course_id=course.course_id, publication_date=datetime.datetime.now()) + database_session.add(announcement) + await database_session.commit() + return announcement diff --git a/tests/test_database/test_crud/test_ufora_announcements.py b/tests/test_database/test_crud/test_ufora_announcements.py index ba6564a..b2303a6 100644 --- a/tests/test_database/test_crud/test_ufora_announcements.py +++ b/tests/test_database/test_crud/test_ufora_announcements.py @@ -7,24 +7,6 @@ from database.crud import ufora_announcements as crud from database.models import UforaAnnouncement, UforaCourse -@pytest.fixture -async def course(database_session: AsyncSession) -> UforaCourse: - """Fixture to create a course""" - course = UforaCourse(name="test", code="code", year=1, log_announcements=True) - database_session.add(course) - await database_session.commit() - return course - - -@pytest.fixture -async def announcement(course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement: - """Fixture to create an announcement""" - announcement = UforaAnnouncement(course_id=course.course_id, publication_date=datetime.datetime.now()) - database_session.add(announcement) - await database_session.commit() - return announcement - - async def test_get_courses_with_announcements_none(database_session: AsyncSession): """Test getting all courses with announcements when there are none""" results = await crud.get_courses_with_announcements(database_session) diff --git a/tests/test_database/test_crud/test_ufora_courses.py b/tests/test_database/test_crud/test_ufora_courses.py new file mode 100644 index 0000000..efe8fe6 --- /dev/null +++ b/tests/test_database/test_crud/test_ufora_courses.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from database.crud import ufora_courses as crud +from database.models import UforaCourse + + +async def test_get_course_by_name_exact(database_session: AsyncSession, course: UforaCourse): + """Test getting a course by its name when the query is an exact match""" + match = await crud.get_course_by_name(database_session, "Test") + assert match == course + + +async def test_get_course_by_name_substring(database_session: AsyncSession, course: UforaCourse): + """Test getting a course by its name when the query is a substring""" + match = await crud.get_course_by_name(database_session, "es") + assert match == course + + +async def test_get_course_by_name_alias(database_session: AsyncSession, course_with_alias: UforaCourse): + """Test getting a course by its name when the name doesn't match, but the alias does""" + match = await crud.get_course_by_name(database_session, "ali") + assert match == course_with_alias From f0a05c8b4d78c78ac1182f433afa048f43cd3ed2 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 14 Jul 2022 22:03:56 +0200 Subject: [PATCH 4/5] Wrap result in a list --- database/crud/ufora_courses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/crud/ufora_courses.py b/database/crud/ufora_courses.py index 152d687..08dd04f 100644 --- a/database/crud/ufora_courses.py +++ b/database/crud/ufora_courses.py @@ -9,7 +9,7 @@ from database.models import UforaCourse, UforaCourseAlias async def get_all_courses(session: AsyncSession) -> list[UforaCourse]: """Get a list of all courses in the database""" statement = select(UforaCourse) - return (await session.execute(statement)).scalars().all() + return list((await session.execute(statement)).scalars().all()) async def get_course_by_name(session: AsyncSession, query: str) -> Optional[UforaCourse]: From 72c3acbcc2068efc2fb997f2244df2262a4588da Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 14 Jul 2022 22:44:22 +0200 Subject: [PATCH 5/5] Add simple caching implementation for database queries that will be used in command autocompletion --- database/crud/ufora_courses.py | 2 + database/utils/caches.py | 74 +++++++++++++++++++ didier/didier.py | 7 ++ tests/conftest.py | 36 ++++++++- tests/test_database/test_crud/conftest.py | 34 --------- .../test_crud/test_ufora_announcements.py | 20 ++--- .../test_crud/test_ufora_courses.py | 12 +-- tests/test_database/test_utils/__init__.py | 0 tests/test_database/test_utils/test_caches.py | 27 +++++++ 9 files changed, 162 insertions(+), 50 deletions(-) create mode 100644 database/utils/caches.py delete mode 100644 tests/test_database/test_crud/conftest.py create mode 100644 tests/test_database/test_utils/__init__.py create mode 100644 tests/test_database/test_utils/test_caches.py diff --git a/database/crud/ufora_courses.py b/database/crud/ufora_courses.py index 08dd04f..d41846c 100644 --- a/database/crud/ufora_courses.py +++ b/database/crud/ufora_courses.py @@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.models import UforaCourse, UforaCourseAlias +__all__ = ["get_all_courses", "get_course_by_name"] + async def get_all_courses(session: AsyncSession) -> list[UforaCourse]: """Get a list of all courses in the database""" diff --git a/database/utils/caches.py b/database/utils/caches.py new file mode 100644 index 0000000..5e5be4f --- /dev/null +++ b/database/utils/caches.py @@ -0,0 +1,74 @@ +from abc import ABC, abstractmethod + +from sqlalchemy.ext.asyncio import AsyncSession + +from database.crud import ufora_courses + +__all__ = ["CacheManager"] + + +class DatabaseCache(ABC): + """Base class for a simple cache-like structure + + The goal of this class is to store data for Discord auto-completion results + that would otherwise potentially put heavy load on the database. + + This only stores strings, to avoid having to constantly refresh these objects. + Once a choice has been made, it can just be pulled out of the database. + + Considering the fact that a user isn't obligated to choose something from the suggestions, + chances are high we have to go to the database for the final action either way. + + Also stores the data in lowercase to allow fast searching + """ + + data: list[str] = [] + data_transformed: list[str] = [] + + def clear(self): + """Remove everything""" + self.data.clear() + + @abstractmethod + async def refresh(self, database_session: AsyncSession): + """Refresh the data stored in this cache""" + + async def invalidate(self, database_session: AsyncSession): + """Invalidate the data stored in this cache""" + await self.refresh(database_session) + + def get_autocomplete_suggestions(self, query: str): + """Filter the cache to find everything that matches the search query""" + query = query.lower() + # Return the original (non-transformed) version of the data for pretty display in Discord + return [self.data[index] for index, value in enumerate(self.data_transformed) if query in value] + + +class UforaCourseCache(DatabaseCache): + """Cache to store the names of Ufora courses""" + + async def refresh(self, database_session: AsyncSession): + self.clear() + + courses = await ufora_courses.get_all_courses(database_session) + + # Load the course names + all the aliases + for course in courses: + aliases = list(map(lambda x: x.alias, course.aliases)) + self.data.extend([course.name, *aliases]) + + self.data.sort() + self.data_transformed = list(map(str.lower, self.data)) + + +class CacheManager: + """Class that keeps track of all caches""" + + ufora_courses: UforaCourseCache + + def __init__(self): + self.ufora_courses = UforaCourseCache() + + async def initialize_caches(self, database_session: AsyncSession): + """Initialize the contents of all caches""" + await self.ufora_courses.refresh(database_session) diff --git a/didier/didier.py b/didier/didier.py index fbe855b..9fef227 100644 --- a/didier/didier.py +++ b/didier/didier.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession import settings from database.crud import custom_commands from database.engine import DBSession +from database.utils.caches import CacheManager from didier.utils.discord.prefix import get_prefix __all__ = ["Didier"] @@ -16,6 +17,7 @@ __all__ = ["Didier"] class Didier(commands.Bot): """DIDIER <3""" + database_caches: CacheManager initial_extensions: tuple[str, ...] = () http_session: ClientSession @@ -50,6 +52,11 @@ class Didier(commands.Bot): await self._load_initial_extensions() await self._load_directory_extensions("didier/cogs") + # Initialize caches + self.database_caches = CacheManager() + async with self.db_session as session: + await self.database_caches.initialize_caches(session) + # Create aiohttp session self.http_session = ClientSession() diff --git a/tests/conftest.py b/tests/conftest.py index b2a1e04..c8ab65f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import asyncio +import datetime from typing import AsyncGenerator, Generator from unittest.mock import MagicMock @@ -6,9 +7,11 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from database.engine import engine -from database.models import Base +from database.models import Base, UforaAnnouncement, UforaCourse, UforaCourseAlias from didier import Didier +"""General fixtures""" + @pytest.fixture(scope="session") def event_loop() -> Generator: @@ -54,3 +57,34 @@ def mock_client() -> Didier: mock_client.user = mock_user return mock_client + + +"""Fixtures to put fake data in the database""" + + +@pytest.fixture +async def ufora_course(database_session: AsyncSession) -> UforaCourse: + """Fixture to create a course""" + course = UforaCourse(name="test", code="code", year=1, log_announcements=True) + database_session.add(course) + await database_session.commit() + return course + + +@pytest.fixture +async def ufora_course_with_alias(database_session: AsyncSession, ufora_course: UforaCourse) -> UforaCourse: + """Fixture to create a course with an alias""" + alias = UforaCourseAlias(course_id=ufora_course.course_id, alias="alias") + database_session.add(alias) + await database_session.commit() + await database_session.refresh(ufora_course) + return ufora_course + + +@pytest.fixture +async def ufora_announcement(ufora_course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement: + """Fixture to create an announcement""" + announcement = UforaAnnouncement(course_id=ufora_course.course_id, publication_date=datetime.datetime.now()) + database_session.add(announcement) + await database_session.commit() + return announcement diff --git a/tests/test_database/test_crud/conftest.py b/tests/test_database/test_crud/conftest.py deleted file mode 100644 index be5f889..0000000 --- a/tests/test_database/test_crud/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -import datetime - -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from database.models import UforaAnnouncement, UforaCourse, UforaCourseAlias - - -@pytest.fixture -async def course(database_session: AsyncSession) -> UforaCourse: - """Fixture to create a course""" - course = UforaCourse(name="test", code="code", year=1, log_announcements=True) - database_session.add(course) - await database_session.commit() - return course - - -@pytest.fixture -async def course_with_alias(database_session: AsyncSession, course: UforaCourse) -> UforaCourse: - """Fixture to create a course with an alias""" - alias = UforaCourseAlias(course_id=course.course_id, alias="alias") - database_session.add(alias) - await database_session.commit() - await database_session.refresh(course) - return course - - -@pytest.fixture -async def announcement(course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement: - """Fixture to create an announcement""" - announcement = UforaAnnouncement(course_id=course.course_id, publication_date=datetime.datetime.now()) - database_session.add(announcement) - await database_session.commit() - return announcement diff --git a/tests/test_database/test_crud/test_ufora_announcements.py b/tests/test_database/test_crud/test_ufora_announcements.py index b2303a6..b2385a2 100644 --- a/tests/test_database/test_crud/test_ufora_announcements.py +++ b/tests/test_database/test_crud/test_ufora_announcements.py @@ -25,19 +25,21 @@ async def test_get_courses_with_announcements(database_session: AsyncSession): assert results[0] == course_1 -async def test_create_new_announcement(course: UforaCourse, database_session: AsyncSession): +async def test_create_new_announcement(ufora_course: UforaCourse, database_session: AsyncSession): """Test creating a new announcement""" - await crud.create_new_announcement(database_session, 1, course=course, publication_date=datetime.datetime.now()) - await database_session.refresh(course) - assert len(course.announcements) == 1 + await crud.create_new_announcement( + database_session, 1, course=ufora_course, publication_date=datetime.datetime.now() + ) + await database_session.refresh(ufora_course) + assert len(ufora_course.announcements) == 1 -async def test_remove_old_announcements(announcement: UforaAnnouncement, database_session: AsyncSession): +async def test_remove_old_announcements(ufora_announcement: UforaAnnouncement, database_session: AsyncSession): """Test removing all stale announcements""" - course = announcement.course - announcement.publication_date -= datetime.timedelta(weeks=2) - announcement_2 = UforaAnnouncement(course_id=announcement.course_id, publication_date=datetime.datetime.now()) - database_session.add_all([announcement, announcement_2]) + course = ufora_announcement.course + ufora_announcement.publication_date -= datetime.timedelta(weeks=2) + announcement_2 = UforaAnnouncement(course_id=ufora_announcement.course_id, publication_date=datetime.datetime.now()) + database_session.add_all([ufora_announcement, announcement_2]) await database_session.commit() await database_session.refresh(course) assert len(course.announcements) == 2 diff --git a/tests/test_database/test_crud/test_ufora_courses.py b/tests/test_database/test_crud/test_ufora_courses.py index efe8fe6..d2d5e1b 100644 --- a/tests/test_database/test_crud/test_ufora_courses.py +++ b/tests/test_database/test_crud/test_ufora_courses.py @@ -4,19 +4,19 @@ from database.crud import ufora_courses as crud from database.models import UforaCourse -async def test_get_course_by_name_exact(database_session: AsyncSession, course: UforaCourse): +async def test_get_course_by_name_exact(database_session: AsyncSession, ufora_course: UforaCourse): """Test getting a course by its name when the query is an exact match""" match = await crud.get_course_by_name(database_session, "Test") - assert match == course + assert match == ufora_course -async def test_get_course_by_name_substring(database_session: AsyncSession, course: UforaCourse): +async def test_get_course_by_name_substring(database_session: AsyncSession, ufora_course: UforaCourse): """Test getting a course by its name when the query is a substring""" match = await crud.get_course_by_name(database_session, "es") - assert match == course + assert match == ufora_course -async def test_get_course_by_name_alias(database_session: AsyncSession, course_with_alias: UforaCourse): +async def test_get_course_by_name_alias(database_session: AsyncSession, ufora_course_with_alias: UforaCourse): """Test getting a course by its name when the name doesn't match, but the alias does""" match = await crud.get_course_by_name(database_session, "ali") - assert match == course_with_alias + assert match == ufora_course_with_alias diff --git a/tests/test_database/test_utils/__init__.py b/tests/test_database/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_database/test_utils/test_caches.py b/tests/test_database/test_utils/test_caches.py new file mode 100644 index 0000000..09583d3 --- /dev/null +++ b/tests/test_database/test_utils/test_caches.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models import UforaCourse +from database.utils.caches import UforaCourseCache + + +async def test_ufora_course_cache_refresh_empty(database_session: AsyncSession, ufora_course_with_alias: UforaCourse): + """Test loading the data for the Ufora Course cache when it's empty""" + cache = UforaCourseCache() + await cache.refresh(database_session) + + assert len(cache.data) == 2 + assert cache.data == ["alias", "test"] + + +async def test_ufora_course_cache_refresh_not_empty( + database_session: AsyncSession, ufora_course_with_alias: UforaCourse +): + """Test loading the data for the Ufora Course cache when it's not empty anymore""" + cache = UforaCourseCache() + cache.data = ["Something"] + cache.data_transformed = ["something"] + + await cache.refresh(database_session) + + assert len(cache.data) == 2 + assert cache.data == ["alias", "test"]