diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 33624a9..382bc76 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -7,7 +7,7 @@ jobs: dependencies: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v2 with: @@ -24,8 +24,21 @@ jobs: tests: needs: [dependencies] runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_DB: didier_action + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v2 with: @@ -41,11 +54,15 @@ jobs: run: pip3 install -r requirements.txt -r requirements-dev.txt - name: Run Pytest run: pytest tests + env: + DB_TEST_SQLITE: false + DB_NAME: didier_action + DB_PASSWORD: postgres linting: needs: [dependencies] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v2 with: @@ -65,7 +82,7 @@ jobs: needs: [dependencies] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v2 with: @@ -85,7 +102,7 @@ jobs: needs: [dependencies] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v2 with: diff --git a/.gitignore b/.gitignore index a14d6d0..518f9fa 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,6 @@ cython_debug/ # PyCharm .idea/ + +# SQLite testing database +tests.db \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7fa7c98 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,105 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/versions/9e8ce58c0a26_initial_migration_ufora_announcements.py b/alembic/versions/9e8ce58c0a26_initial_migration_ufora_announcements.py new file mode 100644 index 0000000..7b7d3f8 --- /dev/null +++ b/alembic/versions/9e8ce58c0a26_initial_migration_ufora_announcements.py @@ -0,0 +1,52 @@ +"""Initial migration: Ufora announcements + +Revision ID: 9e8ce58c0a26 +Revises: +Create Date: 2022-06-17 01:36:02.767151 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9e8ce58c0a26' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ufora_courses', + sa.Column('course_id', sa.Integer(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('code', sa.Text(), nullable=False), + sa.Column('year', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('course_id'), + sa.UniqueConstraint('code'), + sa.UniqueConstraint('name') + ) + op.create_table('ufora_announcements', + sa.Column('announcement_id', sa.Integer(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['course_id'], ['ufora_courses.course_id'], ), + sa.PrimaryKeyConstraint('announcement_id') + ) + op.create_table('ufora_course_aliases', + sa.Column('alias_id', sa.Integer(), nullable=False), + sa.Column('alias', sa.Text(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['course_id'], ['ufora_courses.course_id'], ), + sa.PrimaryKeyConstraint('alias_id'), + sa.UniqueConstraint('alias') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ufora_course_aliases') + op.drop_table('ufora_announcements') + op.drop_table('ufora_courses') + # ### end Alembic commands ### diff --git a/database/engine.py b/database/engine.py index fbdbfd2..2c1815f 100644 --- a/database/engine.py +++ b/database/engine.py @@ -6,17 +6,27 @@ from sqlalchemy.orm import sessionmaker import settings -encoded_password = quote_plus(settings.DB_PASSWORD) -engine = create_async_engine( - URL.create( - drivername="postgresql+asyncpg", - username=settings.DB_USERNAME, - password=encoded_password, - host=settings.DB_HOST, - port=settings.DB_PORT, - database=settings.DB_NAME, - ), - pool_pre_ping=True, -) +# Run local tests against SQLite instead of Postgres +if settings.DB_TEST_SQLITE: + engine = create_async_engine( + URL.create( + drivername="sqlite+aiosqlite", + database="tests.db", + ), + connect_args={"check_same_thread": False}, + ) +else: + encoded_password = quote_plus(settings.DB_PASSWORD) + engine = create_async_engine( + URL.create( + drivername="postgresql+asyncpg", + username=settings.DB_USERNAME, + password=encoded_password, + host=settings.DB_HOST, + port=settings.DB_PORT, + database=settings.DB_NAME, + ), + pool_pre_ping=True, + ) DBSession = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession, expire_on_commit=False) diff --git a/database/models.py b/database/models.py index 8dc24d0..3d778eb 100644 --- a/database/models.py +++ b/database/models.py @@ -1,5 +1,47 @@ from __future__ import annotations -from sqlalchemy.orm import declarative_base +from sqlalchemy import Column, Integer, Text, ForeignKey +from sqlalchemy.orm import declarative_base, relationship Base = declarative_base() + + +class UforaCourse(Base): + """A course on Ufora""" + + __tablename__ = "ufora_courses" + + course_id: int = Column(Integer, primary_key=True) + name: str = Column(Text, nullable=False, unique=True) + code: str = Column(Text, nullable=False, unique=True) + year: int = Column(Integer, nullable=False) + + announcements: list[UforaAnnouncement] = relationship( + "UforaAnnouncement", back_populates="course", cascade="all, delete-orphan" + ) + aliases: list[UforaCourseAlias] = relationship( + "UforaCourseAlias", back_populates="course", cascade="all, delete-orphan" + ) + + +class UforaCourseAlias(Base): + """An alias for a course on Ufora that we use to refer to them""" + + __tablename__ = "ufora_course_aliases" + + alias_id: int = Column(Integer, primary_key=True) + alias: str = Column(Text, nullable=False, unique=True) + course_id: int = Column(Integer, ForeignKey("ufora_courses.course_id")) + + course: UforaCourse = relationship("UforaCourse", back_populates="aliases", uselist=False) + + +class UforaAnnouncement(Base): + """An announcement sent on Ufora""" + + __tablename__ = "ufora_announcements" + + announcement_id = Column(Integer, primary_key=True) + course_id = Column(Integer, ForeignKey("ufora_courses.course_id")) + + course: UforaCourse = relationship("UforaCourse", back_populates="announcements", uselist=False) diff --git a/pyproject.toml b/pyproject.toml index a5ef1e7..a1a3d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ line-length = 120 plugins = [ "sqlalchemy.ext.mypy.plugin" ] +[[tool.mypy.overrides]] +module = "discord.*" +ignore_missing_imports = true [tool.pylint.master] disable = [ @@ -18,4 +21,10 @@ max-line-length = 120 good-names = ["i"] [tool.pytest.ini_options] -asyncio_mode = "auto" \ No newline at end of file +asyncio_mode = "auto" +env = [ + "DB_NAME = didier_action", + "DB_USERNAME = postgres", + "DB_HOST = localhost", + "DISC_TOKEN = token" +] diff --git a/settings.py b/settings.py index 5ef9350..cd7e541 100644 --- a/settings.py +++ b/settings.py @@ -14,6 +14,7 @@ DB_USERNAME: str = env.str("DB_USERNAME", "postgres") DB_PASSWORD: str = env.str("DB_PASSWORD", "") DB_HOST: str = env.str("DB_HOST", "localhost") DB_PORT: int = env.int("DB_PORT", "5432") +DB_TEST_SQLITE: bool = env.bool("DB_TEST_SQLITE", True) """Discord""" DISCORD_TOKEN: str = env.str("DISC_TOKEN") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1ae9878 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +import os +from typing import AsyncGenerator + +import pytest + +from alembic import command, config +from sqlalchemy.ext.asyncio import AsyncSession + +from database.engine import engine + + +@pytest.fixture(scope="session") +def tables(): + """Initialize a database before the tests, and then tear it down again + Starts from an empty database and runs through all the migrations to check those as well + while we're at it + """ + alembic_config = config.Config("alembic.ini") + command.upgrade(alembic_config, "head") + yield + command.downgrade(alembic_config, "base") + + +@pytest.fixture +async def database_session(tables) -> AsyncGenerator[AsyncSession, None]: + """Fixture to create a session for every test + Rollbacks the transaction afterwards so that the future tests start with a clean database + """ + connection = await engine.connect() + transaction = await connection.begin() + session = AsyncSession(bind=connection, expire_on_commit=False) + + yield session + + # Clean up session & rollback transactions + await session.close() + if transaction.is_valid: + await transaction.rollback() + + await connection.close() diff --git a/tests/test_dummy.py b/tests/test_dummy.py index f4f5361..569bcda 100644 --- a/tests/test_dummy.py +++ b/tests/test_dummy.py @@ -1,2 +1,2 @@ -def test_dummy(): +def test_dummy(tables): assert True