Compare commits

..

5 Commits

36 changed files with 606 additions and 47 deletions

25
.flake8
View File

@ -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
./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

View File

@ -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

View File

@ -10,5 +10,5 @@ coverage:
precision: 5
ignore:
- "./tests/*"
- "./didier/cogs/*" # Cogs can't really be tested properly
- "./tests/*"

View File

@ -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.
"""

View File

@ -0,0 +1,32 @@
from typing import Optional
from sqlalchemy import select
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"""
statement = select(UforaCourse)
return list((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

View File

@ -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()

View File

@ -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)

View File

@ -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()))

View File

@ -0,0 +1,24 @@
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.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 = 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):
"""Load the cog"""
await client.add_cog(Other(client))

View File

@ -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)

View File

View File

@ -0,0 +1,17 @@
from aiohttp import ClientSession
from didier.data.embeds.urban_dictionary import Definition
__all__ = ["lookup", "PER_PAGE"]
PER_PAGE = 10
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"]))

View File

@ -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(EmbedBaseModel, BaseModel, ABC):
"""Pydantic version of EmbedModel"""

View File

@ -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

View File

@ -0,0 +1,62 @@
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"]
class Definition(EmbedPydantic):
"""A definition from the Urban Dictionary"""
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(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

View File

@ -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__)

View File

@ -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
@ -42,11 +44,19 @@ 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")
# 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()
@ -113,7 +123,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):

View File

View File

@ -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}")

View File

@ -0,0 +1,7 @@
import discord
__all__ = ["urban_dictionary_green"]
def urban_dictionary_green() -> discord.Colour:
return discord.Colour.from_rgb(220, 255, 0)

View File

@ -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

View File

@ -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

View File

@ -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.
"""

View File

@ -1,11 +1,24 @@
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:
"""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

View File

@ -14,7 +14,8 @@ omit = [
"./database/migrations.py",
"./didier/cogs/*",
"./didier/didier.py",
"./didier/data/*"
"./didier/data/*",
"./didier/utils/discord/colours.py"
]
[tool.isort]
@ -22,6 +23,7 @@ profile = "black"
[tool.mypy]
plugins = [
"pydantic.mypy",
"sqlalchemy.ext.mypy.plugin"
]
[[tool.mypy.overrides]]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,134 @@
{
"list": [
{
"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": "lightinglax",
"word": "covfefe",
"defid": 11630874,
"current_vote": "",
"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": "An [unpresidented] [typo]",
"permalink": "http://covfefe.urbanup.com/11631018",
"thumbs_up": 2295,
"sound_urls": [],
"author": "John Wilkes Bluetooth",
"word": "covfefe",
"defid": 11631018,
"current_vote": "",
"written_on": "2017-05-31T05:21:10.475Z",
"example": "[Despite] the [constant] [negative press] covfefe",
"thumbs_down": 776
},
{
"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": "covfefe89",
"word": "covfefe",
"defid": 11631031,
"current_vote": "",
"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": "¯\\_(ツ)_/¯",
"permalink": "http://covfefe.urbanup.com/11630896",
"thumbs_up": 245,
"sound_urls": [],
"author": "zanzalaz",
"word": "covfefe",
"defid": 11630896,
"current_vote": "",
"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 [--] Cant [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
}
]
}

View File

@ -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)
@ -43,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

View File

@ -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, 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 == ufora_course
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 == ufora_course
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 == ufora_course_with_alias

View File

@ -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"]