Skip to content
This repository has been archived by the owner on Dec 11, 2021. It is now read-only.

Migrate reminder endpoints over from site, set up the testing suite #30

Closed
wants to merge 10 commits into from
23 changes: 7 additions & 16 deletions .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,7 @@ jobs:
PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache

AUTH_TOKEN: ci-token
DATABASE_URL: postgresql+asyncpg://pysite:pysite@postgres:5432/pysite

# Via https://github.com/actions/example-services/blob/main/.github/workflows/postgres-service.yml
# services:
# postgres:
# image: postgres:13
# env:
# POSTGRES_USER: postgres
# POSTGRES_PASSWORD: postgres
# POSTGRES_DB: postgres
# ports:
# # Assign a random TCP port
# - 5432/tcp
# # needed because the postgres container does not provide a healthcheck
# options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
DATABASE_URL: postgresql+asyncpg://pydisapi:pydisapi@localhost:7777/pydisapi
D0rs4n marked this conversation as resolved.
Show resolved Hide resolved

steps:
- name: Add custom PYTHONUSERBASE to PATH
Expand All @@ -52,6 +38,11 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: '3.9'

# Start the database early to give it a chance to get ready before
# we start running tests.
- name: Run database using docker-compose
run: docker-compose run -d -p 7777:5432 --name pydisapi postgres

# This step caches our Python dependencies. To make sure we
# only restore a cache when the dependencies, the python version,
Expand Down Expand Up @@ -100,7 +91,7 @@ jobs:
[flake8] %(code)s: %(text)s'"

- name: Run pytest
run: pytest
run: pytest -n auto
env:
POSTGRES_HOST: localhost
# Get the published port.
Expand Down
4 changes: 2 additions & 2 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def run_migrations_offline() -> None:
context.run_migrations()


def do_run_migrations(connection: AsyncConnection):
def do_run_migrations(connection: AsyncConnection) -> None:
"""Run all migrations on the given connection."""
context.configure(
connection=connection,
Expand All @@ -61,7 +61,7 @@ def do_run_migrations(connection: AsyncConnection):
context.run_migrations()


async def run_migrations_online():
async def run_migrations_online() -> None:
"""
Run migrations in 'online' mode.

Expand Down
4 changes: 2 additions & 2 deletions api/core/database/models/api/bot/nomination.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Nomination(Base):
__tablename__ = "api_nomination"

# Whether this nomination is still relevant.
active = Column(Boolean, nullable=False)
active = Column(Boolean, nullable=False, default=True)

user_id = Column(
ForeignKey(
Expand All @@ -28,7 +28,7 @@ class Nomination(Base):
id = Column(Integer, primary_key=True, autoincrement=True)

# Why the nomination was ended.
end_reason = Column(Text, nullable=False)
end_reason = Column(Text, nullable=False, default="")

# When the nomination was ended.
ended_at = Column(DateTime(True))
Expand Down
2 changes: 1 addition & 1 deletion api/core/database/models/api/bot/reminder.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class Reminder(Base):

# Whether this reminder is still active.
# If not, it has been sent out to the user.
active = Column(Boolean, nullable=False)
active = Column(Boolean, nullable=False, default=True)
# The channel ID that this message was
# sent in, taken from Discord.
channel_id = Column(BigInteger, nullable=False)
Expand Down
2 changes: 1 addition & 1 deletion api/core/database/models/api/bot/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class User(Base):
in_guild = Column(Boolean, nullable=False, default=True)

# IDs of roles the user has on the server
roles = Column(ARRAY(BigInteger()), nullable=False)
roles = Column(ARRAY(BigInteger()), nullable=False, default=[])

@validates("id")
def validate_user_id(self, _key: str, user_id: int) -> Union[int, NoReturn]:
Expand Down
8 changes: 8 additions & 0 deletions api/endpoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@
There are currently no plan to use a strictly versioned API design, as this API
is currently tightly coupled with a single client application.
"""

from fastapi import APIRouter

from .reminder.reminder_endpoints import reminder

bot_router = APIRouter(prefix="/bot")

bot_router.include_router(reminder)
Empty file.
16 changes: 16 additions & 0 deletions api/endpoints/dependencies/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

from api.core import settings

engine = create_async_engine(settings.database_url, future=True)
session_factory = sessionmaker(engine, class_=AsyncSession)


async def create_database_session() -> None:
"""A FastAPI dependency that creates an SQLAlchemy session."""
try:
async with session_factory() as session:
yield session
finally:
await session.close()
1 change: 1 addition & 0 deletions api/endpoints/reminder/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .reminder_endpoints import reminder
8 changes: 8 additions & 0 deletions api/endpoints/reminder/reminder_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import Depends

from .reminder_schemas import ReminderFilter


async def filter_values(reminder_filter: ReminderFilter = Depends()) -> dict:
"""Returns a dictionary exported from a ReminderFilter model from the Path, with None values excluded."""
return reminder_filter.dict(exclude_none=True)
223 changes: 223 additions & 0 deletions api/endpoints/reminder/reminder_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
from typing import Optional, Union

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession

from api.core.database.models.api.bot import Reminder, User
from api.core.schemas import ErrorMessage
from api.endpoints.dependencies.database import create_database_session
from .reminder_dependencies import filter_values
from .reminder_schemas import ReminderCreateIn, ReminderPatchIn, ReminderResponse

reminder = APIRouter(prefix="/reminders")


@reminder.get(
"/",
status_code=200,
response_model=list[ReminderResponse],
response_model_by_alias=False,
responses={404: {"model": ErrorMessage}},
)
async def get_reminders(
db_session: AsyncSession = Depends(create_database_session),
db_filter_values: dict = Depends(filter_values),
) -> Union[JSONResponse, list[ReminderResponse], None]:
"""
### GET /bot/reminders.

Returns all reminders in the database.
#### Response format
>>> [
... {
... 'active': True,
... 'author': 1020103901030,
... 'mentions': [
... 336843820513755157,
... 165023948638126080,
... 267628507062992896
... ],
... 'content': "Make dinner",
... 'expiration': '5018-11-20T15:52:00Z',
... 'id': 11,
... 'channel_id': 634547009956872193,
... 'jump_url': "https://discord.com/channels/<guild_id>/<channel_id>/<message_id>",
... 'failures': 3
... },
... ...
... ]
#### Status codes
- 200: returned on success
## Authentication
Requires an API token.
"""
if not db_filter_values:
if not (results := (await db_session.execute(select(Reminder))).scalars().all()):
return []
return results
elif not (filtered_results := (await db_session.execute(
select(Reminder).
filter_by(**db_filter_values))
).scalars().all()):
return JSONResponse(
status_code=404,
content={
"error": "There are no reminders with the specified filter values."
},
)
else:
return filtered_results


@reminder.get(
"/{reminder_id}",
status_code=200,
response_model=ReminderResponse,
response_model_by_alias=False,
responses={404: {"model": ErrorMessage}},
)
async def get_reminder_by_id(
reminder_id: int, db_session: AsyncSession = Depends(create_database_session)
) -> Union[JSONResponse, ReminderResponse]:
"""
### GET /bot/reminders/<id:int>.

Fetches the reminder with the given id.
#### Response format
>>>
... {
... 'active': True,
... 'author': 1020103901030,
... 'mentions': [
... 336843820513755157,
... 165023948638126080,
... 267628507062992896
... ],
... 'content': "Make dinner",
... 'expiration': '5018-11-20T15:52:00Z',
... 'id': 11,
... 'channel_id': 634547009956872193,
... 'jump_url': "https://discord.com/channels/<guild_id>/<channel_id>/<message_id>",
... 'failures': 3
... }
#### Status codes
- 200: returned on success
- 404: returned when the reminder doesn't exist
## Authentication
Requires an API token.
"""
if not (result := (await db_session.execute(select(Reminder).filter_by(id=reminder_id))).scalars().first()):
return JSONResponse(
status_code=404,
content={"error": "There is no reminder in the database with that id!"},
)
return result


@reminder.patch(
"/{reminder_id}",
status_code=200,
responses={404: {"model": ErrorMessage}, 400: {"model": ErrorMessage}},
)
async def edit_reminders(
reminder_id: int,
reminder_patch_in: ReminderPatchIn,
db_session: AsyncSession = Depends(create_database_session),
) -> Optional[JSONResponse]:
"""
### PATCH /bot/reminders/<id:int>.

Update the user with the given `id`.
All fields in the request body are optional.
#### Request body
>>> {
... 'mentions': list[int],
... 'content': str,
... 'expiration': str, # ISO-formatted datetime
... 'failures': int
... }
#### Status codes
- 200: returned on success
- 400: if the body format is invalid
- 404: if no user with the given ID could be found
## Authentication
Requires an API token.
"""
if not (await db_session.execute(select(Reminder).filter_by(id=reminder_id))).scalars().first():
return JSONResponse(
status_code=404,
content={"error": "There is no reminder with that id in the database!"},
)
await db_session.execute(
update(Reminder).where(Reminder.id == reminder_id).values(**reminder_patch_in.dict(exclude_none=True))
)
await db_session.commit()


@reminder.post(
"/",
status_code=201,
responses={404: {"model": ErrorMessage}, 400: {"model": ErrorMessage}},
)
async def create_reminders(
reminder_in: ReminderCreateIn,
db_session: AsyncSession = Depends(create_database_session),
) -> Optional[JSONResponse]:
"""
### POST /bot/reminders.

Create a new reminder.
#### Request body
>>> {
... 'author': int,
... 'mentions': list[int],
... 'content': str,
... 'expiration': str, # ISO-formatted datetime
... 'channel_id': int,
... 'jump_url': str
... }
#### Status codes
- 201: returned on success
- 400: if the body format is invalid
- 404: if no user with the given ID could be found
## Authentication
Requires an API token.
"""
if not (await db_session.execute(select(User).filter_by(id=reminder_in.author_id))).scalars().first():
return JSONResponse(
status_code=404,
content={"error": "There is no user with that id in the database!"},
)
new_reminder = Reminder(**reminder_in.dict())
db_session.add(new_reminder)
await db_session.commit()


@reminder.delete(
"/{reminder_id}", status_code=204, responses={404: {"model": ErrorMessage}}
)
async def delete_reminders(
reminder_id: int, db_session: AsyncSession = Depends(create_database_session)
) -> Optional[JSONResponse]:
"""
### DELETE /bot/reminders/<id:int>.

Delete the reminder with the given `id`.
#### Status codes
- 204: returned on success
- 404: if a reminder with the given `id` does not exist
## Authentication
Requires an API token.
"""
if not (reminder_to_delete := (await db_session.execute(
select(Reminder).
filter_by(id=reminder_id))
).scalars().first()):
return JSONResponse(
status_code=404,
content={"error": "There is no reminder with that id in the database"},
)
await db_session.delete(reminder_to_delete)
await db_session.commit()
Loading