Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merging 'develop' branch into main #19

Merged
merged 22 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .env_example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# app
USERNAME=
PASSWORD=
CLIENT_ID=
CLIENT_SECRET=
BOT_NAME=
VERSION=v1
DEVELOPER=nickatnight
ENV=dev
SECRET_KEY=test-key
SUB_DOMAIN=api
USE_SENTRY=False

# Database
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_HOST=db
POSTGRES_PORT=5432

# Nginx
NGINX_HOST=localhost
UPSTREAMS=/:backend:8000
ENABLE_SSL=
CERTBOT_EMAIL=
DOMAIN_LIST=

# Redis
REDIS_HOST=redis
REDIS_PORT=6379
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
REGISTRY: ${{ secrets.REGISTRY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_STAGING_HOST: ${{ secrets.POSTGRES_STAGING_HOST }}
PASSWORD: ${{ secrets.PASSWORD }}
CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
with:
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ jobs:
uses: actions/checkout@v3
- name: Run unit tests
run: |
docker compose -f ops/docker-compose.test.yml up --detach --wait
docker compose -f ops/docker-compose.test.yml exec backend pytest --cov-report=xml:/data/coverage.xml --cov=src/ tests/
docker-compose -f ops/docker-compose.test.yml up --exit-code-from backend
- name: Codecov
if: success()
uses: codecov/codecov-action@v3
Expand Down
68 changes: 3 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</a>
<a href="https://github.com/nickatnight/tag-youre-it-backend/releases"><img alt="Release Status" src="https://img.shields.io/github/v/release/nickatnight/tag-youre-it-backend"></a>
<a href="https://github.com/nickatnight/tag-youre-it-backend/releases"><img alt="Python Badge" src="https://img.shields.io/badge/python-3.8%7C3.9%7C3.10%7C3.11-blue"></a>
<a href="https://github.com/nickatnight/tag-youre-it-backend/blob/master/LICENSE">
<a href="https://github.com/nickatnight/tag-youre-it-backend/blob/main/LICENSE">
<img alt="License Shield" src="https://img.shields.io/github/license/nickatnight/tag-youre-it-backend">
</a>
</p>
Expand Down Expand Up @@ -62,13 +62,13 @@ See [r/TagYoureItBot](https://www.reddit.com/r/TagYoureItBot) for more updates.
</a>
</p>

## Usage
## Development
1. `make up`
2. visit `http://localhost:8666/v1/ping` for uvicorn server, or `http://localhost` for nginx server
3. Backend, JSON based web API based on OpenAPI: `http://localhost/v1/`
4. Automatic interactive documentation with Swagger UI (from the OpenAPI backend): `http://localhost/docs`

## Backend local development, additional details
The entrypoint to the bot can be found in `src.core.bot`. In short, for each sub which the bot is enabled, an async process will be pushed onto the event loop (each sub gets their own game engine).

### Migrations

Expand All @@ -83,73 +83,11 @@ $ make alembic-make-migrations "cool comment dude"
$ make alembic-migrate
```

### General workflow
See the [Makefile](/Makefile) to view available commands.

By default, the dependencies are managed with [Poetry](https://python-poetry.org/), go there and install it.

From `./backend/` you can install all the dependencies with:

```console
$ poetry install
```

### pre-commit hooks
If you haven't already done so, download [pre-commit](https://pre-commit.com/) system package and install. Once done, install the git hooks with
```console
$ pre-commit install
pre-commit installed at .git/hooks/pre-commit
```

### Nginx
The Nginx webserver acts like a web proxy, or load balancer rather. Incoming requests can get proxy passed to various upstreams eg. `/:service1:8001,/static:service2:8002`

```yml
volumes:
proxydata-vol:
...
nginx:
image: your-registry/nginx
# OR you can do the following
# build:
# context: ./nginx
# dockerfile: ./Dockerfile
environment:
- UPSTREAMS=/:backend:8000
- NGINX_SERVER_NAME=yourservername.com
- ENABLE_SSL=true
- HTTPS_REDIRECT=true
- [email protected]
- DOMAIN_LIST=yourservername.com
- BASIC_AUTH_USER=user
- BASIC_AUTH_PASS=pass
ports:
- '0.0.0.0:80:80'
- '0.0.0.0:443:443'
volumes:
- proxydata-vol:/etc/letsencrypt
```

Some of the environment variables available:
- `UPSTREAMS=/:backend:8000` a comma separated list of \<path\>:\<upstream\>:\<port\>. Each of those of those elements creates a location block with proxy_pass in it.
- `HTTPS_REDIRECT=true` enabled a standard, ELB compliant https redirect.
- `ENABLE_SSL=true` to enable redirects to https from http
- `NGINX_SERVER_NAME` name of the server and used as path name to store ssl fullchain and privkey
- `[email protected]` the email to register with Certbot.
- `DOMAIN_LIST` domain(s) you are requesting a certificate for.
- `BASIC_AUTH_USER` username for basic auth.
- `BASIC_AUTH_PASS` password for basic auth.

When SSL is enabled, server will install Cerbot in standalone mode and add a new daily periodic script to `/etc/periodic/daily/` to run a cronjob in the background. This allows you to automate cert renewing (every 3 months). See [docker-entrypoint](nginx/docker-entrypoint.sh) for details.
### Deployments
A common scenario is to use an orchestration tool, such as docker swarm, to deploy your containers to the cloud (DigitalOcean). This can be automated via GitHub Actions workflow. See [main.yml](/.github/workflows/main.yml) for more.

You will be required to add `secrets` in your repo settings:
- DIGITALOCEAN_TOKEN: your DigitalOcean api token
- REGISTRY: container registry url where your images are hosted
- POSTGRES_PASSWORD: password to postgres database
- STAGING_HOST_IP: ip address of the staging droplet
- PROD_HOST_IP: ip address of the production droplet
- SSH_KEY: ssh key of user connecting to server

Made with :heart: from Cali
6 changes: 4 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "tag-youre-it-backend"
version = "0.0.2"
version = "0.1.0"
description = "Backend for TagYoureIt Reddit bot"
authors = ["nickatnight <[email protected]>"]

Expand All @@ -19,6 +19,7 @@ fastapi-cache2 = {extras = ["redis"], version = "^0.1.9"}
PyYAML = "^6.0"
httpx = "^0.23.3"
gunicorn = "^20.1.0"
sentry-sdk = "^1.15.0"

[tool.poetry.dev-dependencies]
black = "^22.6.0"
Expand All @@ -30,6 +31,7 @@ pytest-mock = "^3.10.0"
pytest-asyncio = "^0.19.0"
mypy = "^0.982"
ruff = "^0.0.241"
httpx = "^0.23.3"

[tool.isort]
multi_line_output = 3
Expand Down Expand Up @@ -80,7 +82,7 @@ strict_equality = true
# --strict end

[[tool.mypy.overrides]]
module = ["fastapi_cache.*", "redis.*", "asyncpraw.*"]
module = ["fastapi_cache.*", "redis.*", "asyncpraw.*", "asyncprawcore.*"]
ignore_missing_imports = true

[build-system]
Expand Down
9 changes: 7 additions & 2 deletions backend/src/api/v1/game.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import List

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession

from src.core.enums import OrderEnum, SortEnum
from src.db.session import get_session
from src.repositories.game import GameRepository
from src.schemas.common import IGetResponseBase
Expand All @@ -19,11 +20,15 @@
tags=["games"],
)
async def games(
response: Response,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1),
sort: str = Query(default=SortEnum.CREATED_AT),
order: str = Query(default=OrderEnum.DESC),
session: AsyncSession = Depends(get_session),
) -> IGetResponseBase[List[IGameRead]]:
repo = GameRepository(db=session)
games = await repo.all(skip=skip, limit=limit)
games = await repo.all(skip=skip, limit=limit, sort_field=sort, sort_order=order.lower())

response.headers["x-content-range"] = f"{len(games)}/{10}"
return IGetResponseBase[List[IGameRead]](data=games)
9 changes: 7 additions & 2 deletions backend/src/api/v1/player.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import List

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession

from src.core.enums import OrderEnum, SortEnum
from src.db.session import get_session
from src.repositories.player import PlayerRepository
from src.schemas.common import IGetResponseBase
Expand All @@ -19,11 +20,15 @@
tags=["players"],
)
async def players(
response: Response,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1),
sort: str = Query(default=SortEnum.CREATED_AT),
order: str = Query(default=OrderEnum.DESC),
session: AsyncSession = Depends(get_session),
) -> IGetResponseBase[List[IPlayerRead]]:
repo = PlayerRepository(db=session)
players = await repo.all(skip=skip, limit=limit)
players = await repo.all(skip=skip, limit=limit, sort_field=sort, sort_order=order.lower())

response.headers["x-content-range"] = f"{len(players)}/{10}"
return IGetResponseBase[List[IPlayerRead]](data=players)
9 changes: 7 additions & 2 deletions backend/src/api/v1/subreddit.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import List

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession

from src.core.enums import OrderEnum, SortEnum
from src.db.session import get_session
from src.repositories.subreddit import SubRedditRepository
from src.schemas.common import IGetResponseBase
Expand All @@ -19,11 +20,15 @@
tags=["subreddits"],
)
async def subreddits(
response: Response,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1),
sort: str = Query(default=SortEnum.CREATED_AT),
order: str = Query(default=OrderEnum.DESC),
session: AsyncSession = Depends(get_session),
) -> IGetResponseBase[List[ISubRedditRead]]:
repo = SubRedditRepository(db=session)
subreddits = await repo.all(skip=skip, limit=limit)
subreddits = await repo.all(skip=skip, limit=limit, sort_field=sort, sort_order=order.lower())

response.headers["x-content-range"] = f"{len(subreddits)}/{10}"
return IGetResponseBase[List[ISubRedditRead]](data=subreddits)
File renamed without changes.
29 changes: 29 additions & 0 deletions backend/src/clients/reddit/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import platform

import asyncpraw

from src.core.config import settings
from src.interfaces.client import IClient


platform_name = platform.uname()
USER_AGENT: str = f"{platform_name}/{settings.VERSION} ({settings.BOT_NAME} {settings.DEVELOPER});"


class RedditResource(IClient[asyncpraw.Reddit]):
reddit: asyncpraw.Reddit

@classmethod
def configure(cls) -> asyncpraw.Reddit:
reddit_config = {
"client_id": settings.CLIENT_ID,
"client_secret": settings.CLIENT_SECRET,
"username": settings.USERNAME,
"password": settings.PASSWORD,
"user_agent": USER_AGENT,
}
return asyncpraw.Reddit(**reddit_config)

async def close(self) -> None:
"""Close requester"""
_ = await self.reddit.close()
20 changes: 20 additions & 0 deletions backend/src/clients/reddit/inbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import AsyncIterator

from asyncpraw.models import Message

from src.clients.reddit.base import RedditResource


class InboxClient(RedditResource):
def __init__(self, reddit=None) -> None:
self.reddit = reddit or self.configure()

def stream(self) -> AsyncIterator[Message]:
"""stream incoming messages"""
s: AsyncIterator[Message] = self.reddit.inbox.stream()

return s

async def close(self) -> None:
"""Close requester"""
await self.reddit.close()
18 changes: 9 additions & 9 deletions backend/src/core/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from src.core.config import settings
from src.core.engine import GameEngine
from src.core.enums import SupportedSubs
from src.core.enums import SupportedSubs, UserBlacklist
from src.db.session import SessionLocal
from src.repositories import GameRepository, PlayerRepository, SubRedditRepository
from src.services import GameService, PlayerService, SubRedditService
Expand All @@ -27,6 +27,8 @@ async def tag_init(subreddit_name: str = SupportedSubs.TAG_YOURE_IT_BOT) -> None
:return:
None
"""
# TODO: i dont think this is the right way to do this. probably want
# want to create new db connection for each processed message
async with SessionLocal() as session:
player_repo = PlayerRepository(db=session)
game_repo = GameRepository(db=session)
Expand All @@ -38,13 +40,6 @@ async def tag_init(subreddit_name: str = SupportedSubs.TAG_YOURE_IT_BOT) -> None
game=GameService(repo=game_repo),
subreddit=SubRedditService(repo=subreddit_repo),
),
reddit_config={ # type: ignore
"client_id": settings.CLIENT_ID,
"client_secret": settings.CLIENT_SECRET,
"username": settings.USERNAME,
"password": settings.PASSWORD,
"user_agent": f"{platform_name}/{settings.VERSION} ({settings.BOT_NAME} {settings.DEVELOPER});",
},
)

logger.info(f"Game of Tag has started for SubReddit[r/{subreddit_name}]")
Expand All @@ -54,8 +49,13 @@ async def tag_init(subreddit_name: str = SupportedSubs.TAG_YOURE_IT_BOT) -> None
if __name__ == "__main__":
loop = asyncio.get_event_loop()
tasks = []
supported_subs = (
SupportedSubs.all()
if settings.USERNAME == UserBlacklist.TAG_YOURE_IT_BOT
else SupportedSubs.test()
)

for sub in [SupportedSubs.TAG_YOURE_IT_BOT]:
for sub in supported_subs:
task = loop.create_task(tag_init(subreddit_name=sub))
tasks.append(task)

Expand Down
1 change: 1 addition & 0 deletions backend/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Settings(BaseSettings):
ENV: str = Field(default="dev", env="ENV")
VERSION: str = Field(default="v1", env="VERSION")
DEBUG: bool = Field(default=True, env="DEBUG")
USE_SENTRY: bool = Field(default=False, env="USE_SENTRY")

POSTGRES_USER: str = Field(default="", env="POSTGRES_USER")
POSTGRES_PASSWORD: str = Field(default="", env="POSTGRES_PASSWORD")
Expand Down
2 changes: 1 addition & 1 deletion backend/src/core/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class Envs:

FOOTER = (
"^^[&nbsp;how&nbsp;to&nbsp;use]"
"(https://www.reddit.com/r/TagYoureItBot/comments/yi25li/tagyoureitbot_info_v22/)"
"(https://www.reddit.com/r/TagYoureItBot/comments/11bcwi1/tagyoureitbot_info_beta_relase/)"
"&nbsp;|&nbsp;[creator](https://www.reddit.com/message/compose/?to=throwie_one)"
"&nbsp;|&nbsp;[source&nbsp;code](https://github.com/nickatnight/tag-youre-it-backend)"
"&nbsp;|&nbsp;[wikihow](https://www.wikihow.com/Play-Tag)"
Expand Down
Loading