huge refactor

This commit is contained in:
Bad_Investment
2021-05-16 16:39:41 -07:00
parent 9ef28d1584
commit cbfa50572e
38 changed files with 836 additions and 503 deletions

379
app.py
View File

@@ -1,379 +0,0 @@
from fastapi import FastAPI, Request, HTTPException, File, UploadFile
from fastapi.responses import StreamingResponse
from fastapi_crudrouter import OrmarCRUDRouter as CRUDRouter
import asyncio
import sqlalchemy
import ormar
import yaml
from io import StringIO
import asyncpraw
import pandas as pd
from datetime import datetime
from typing import Optional
import logging
from poapbot.models.settings import RedditSettings, DBSettings, FastAPISettings
SETTINGS = yaml.safe_load(open('settings.yaml', 'r'))
REDDIT_SETTINGS = RedditSettings.parse_obj(SETTINGS['reddit'])
DB_SETTINGS = DBSettings.parse_obj(SETTINGS['db'])
API_SETTINGS = FastAPISettings.parse_obj(SETTINGS['fastapi'])
from poapbot.store import EventDataStore
from poapbot.scraper import RedditScraper
from poapbot.bot import RedditBot
from poapbot.models import metadata, database, Event, Attendee, Admin, Claim, RequestMessage, ResponseMessage
engine = sqlalchemy.create_engine(DB_SETTINGS.url)
metadata.create_all(engine)
app = FastAPI(
title=API_SETTINGS.title,
version=API_SETTINGS.version,
openapi_tags=API_SETTINGS.openapi_tags
)
# app.include_router(CRUDRouter(schema=Event))
# app.include_router(CRUDRouter(schema=Attendee))
# app.include_router(CRUDRouter(schema=Claim))
# app.include_router(CRUDRouter(schema=RequestMessage))
# app.include_router(CRUDRouter(schema=ResponseMessage))
app.state.database = database
logging.config.fileConfig('logging.conf', disable_existing_loggers=True)
logger = logging.getLogger(__name__)
@app.on_event('startup')
async def startup_event():
db = app.state.database
if not db.is_connected:
await db.connect()
store = EventDataStore(db)
app.state.store = store
reddit_client = asyncpraw.Reddit(
username=REDDIT_SETTINGS.auth.username,
password=REDDIT_SETTINGS.auth.password.get_secret_value(),
client_id=REDDIT_SETTINGS.auth.client_id,
client_secret=REDDIT_SETTINGS.auth.client_secret.get_secret_value(),
user_agent=REDDIT_SETTINGS.auth.user_agent
)
bot = RedditBot(reddit_client, store)
asyncio.create_task(bot.run())
app.state.scraper = RedditScraper(reddit_client)
app.state.bot = bot
@app.on_event("shutdown")
async def shutdown():
db = app.state.database
if db.is_connected:
await db.disconnect()
@app.post(
"/admin/create_admin",
description="Create Admin",
tags=['admin'],
response_model=Admin
)
async def create_admin(request: Request, username: str):
existing_admin = await Admin.objects.get_or_none(username__exact=username)
if existing_admin:
raise HTTPException(status_code=409, detail=f'Admin with username {username} already exists')
admin = Admin(username=username)
await admin.save()
return admin
@app.post(
"/admin/upload_claims",
tags=['admin']
)
async def upload_claims(request: Request, event_id: str, file: UploadFile = File(...)):
try:
event = await Event.objects.get(pk=event_id)
except ormar.exceptions.NoMatch:
raise HTTPException(status_code=404, detail=f'Event with id "{event_id}" does not exist')
if file.content_type != 'text/csv':
raise HTTPException(status_code=415, detail=f'File must be of type /text/csv, provided: {file.content_type}')
try:
content = await file.read()
df = pd.read_csv(StringIO(content.decode()))
except Exception as e:
raise HTTPException(status_code=500, detail=f'Failed to parse file: {e}')
if 'link' not in df.columns:
raise HTTPException(status_code=400, detail=f'List must have "link" column header')
elif 'username' not in df.columns:
df['username'] = ''
df = df.fillna('')
existing_claims = await Claim.objects.filter(event__id__exact=event_id).all()
existing_links = {c.link:c for c in existing_claims}
existing_usernames = {c.attendee.username:c for c in existing_claims if c.attendee}
success = 0
rejected = []
create_attendees = []
create_claims = []
for index, row in df.iterrows():
if row.username in existing_usernames:
rejected.append({'index':index, 'reason':f'Username {row.username} already has reserved claim'})
continue
elif row.link in existing_links:
rejected.append({'index':index, 'reason':f'Claim link {row.link} already exists'})
continue
elif not row.link:
rejected.append({'index':index, 'reason':f'Invalid link {row.link}'})
if row.username:
attendee = await Attendee.objects.get_or_none(username=row.username)
if not attendee:
attendee = Attendee(username=row.username)
create_attendees.append(attendee)
else:
attendee = None
claim = Claim(attendee=attendee, event=event, link=row.link, reserved=True if attendee else False)
create_claims.append(claim)
success += 1
async with request.app.state.database.transaction():
await Attendee.objects.bulk_create(create_attendees)
await Claim.objects.bulk_create(create_claims)
return {
'success': success,
'rejected': rejected if (len(rejected) <= 100) else len(rejected)
}
@app.get(
"/claims/{id}",
tags=['claims']
)
async def get_claim_by_id(request: Request, id: str):
claim = await Claim.objects.get_or_none(id=id)
if not claim:
raise HTTPException(status_code=404, detail=f'Claim with id {id} does not exist')
return claim
@app.post(
"/claims/",
tags=['claims']
)
async def create_claim(request: Request, event_id: str, link: str, username: str = None):
event = await Event.objects.get_or_none(pk=event_id)
if not event:
raise HTTPException(status_code=404, detail=f'Event with id {event_id} does not exist')
if username:
attendee = Attendee.objects.get_or_create(username=username)
else:
attendee = None
claim = Claim(event=event, attendee=attendee, link=link, reserved=True if attendee else False)
await claim.save()
return claim
@app.delete(
"/claims/{id}",
tags=['claims']
)
async def delete_claim(request: Request, id: str):
claim = await Claim.objects.get_or_none(pk=id)
if not claim:
raise HTTPException(status_code=404, detail=f'Claim with id {id} does not exist')
await claim.delete()
@app.put(
"/claims/{id}/clear_attendee",
tags=['claims']
)
async def clear_claim_attendee(request: Request, id: str):
try:
claim = await Claim.objects.get(pk=id)
except ormar.exceptions.NoMatch:
raise HTTPException(status_code=404, detail=f'Claim with id {id} does not exist')
async with request.app.state.database.transaction():
claim.remove(claim.attendee, 'attendee')
claim.reserved = False
await claim.update()
@app.put(
"/claims/{id}/update_attendee",
tags=['claims']
)
async def update_claim_attendee(request: Request, id: str, username: str):
try:
claim = await Claim.objects.get(pk=id)
except ormar.exceptions.NoMatch:
raise HTTPException(status_code=404, detail=f'Claim with id {id} does not exist')
attendee = await Attendee.objects.get_or_create(username=username)
claim.attendee = attendee
claim.reserved = True
await claim.update()
@app.post(
"/events/",
description="Create Event",
tags=['events'],
response_model=Event
)
async def create_event(
request: Request,
id: str,
name: str,
code: str,
start_date: datetime,
expiry_date: datetime,
description: Optional[str] = "",
minimum_age: Optional[int] = 0,
minimum_karma: Optional[int] = 0
):
existing_event = await Event.objects.get_or_none(pk=id)
if existing_event:
raise HTTPException(status_code=409, detail=f'Event with id {id} already exists')
event = Event(
id=id,
name=name,
code=code.lower(),
description=description,
start_date=start_date,
expiry_date=expiry_date,
minimum_age=minimum_age,
minimum_karma=minimum_karma
)
await event.save()
return event
@app.get(
"/events/{id}",
tags=['events']
)
async def get_event_by_id(request: Request, id: str):
event = await Event.objects.get_or_none(id=id)
if not event:
raise HTTPException(status_code=404, detail=f'Event with id {id} does not exist')
return event
@app.put(
"/events/{id}",
tags=['events']
)
async def update_event(
request: Request,
id: str,
code: str = None,
start_date: datetime = None,
end_date: datetime = None,
minimum_karma: int = None,
minimum_age: int = None
):
event = await Event.objects.get_or_none(pk=id)
if not event:
raise HTTPException(status_code=404, detail=f'Event with id {id} does not exist')
if code:
event.code = code
if start_date:
event.start_date = start_date
if end_date:
event.end_date = end_date
if minimum_karma:
event.minimum_karma = minimum_karma
if minimum_age:
event.minimum_age = minimum_age
await event.update()
return event
@app.delete(
"/events/{id}",
tags=['events']
)
async def delete_event(request: Request, id: str):
try:
await Event.objects.delete(pk=id)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get(
"/scrape/get_usernames_by_submission",
tags=['scrape']
)
async def get_usernames_by_submission(request: Request, submission_id: str, traverse: bool = False):
try:
comments = await request.app.state.scraper.get_comments_by_submission_id(submission_id, traverse)
return list(set([c.author.name for c in comments if c.author]))
except Exception as e:
# todo better exception handling
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/scrape/get_usernames_by_comment",
tags=['scrape']
)
async def get_usernames_by_comment(request: Request, comment_id: str, traverse: bool = False):
try:
comments = await request.app.state.scraper.get_comments_by_comment_id(comment_id, traverse)
return list(set([c.author.name for c in comments if c.author]))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def data_to_csv_response(data, filename):
stream = StringIO(pd.DataFrame(data).fillna('').to_csv(index=False))
response = StreamingResponse(stream, media_type="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename={filename}-{datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S')}.csv"
return response
@app.get(
"/export/events",
tags=['export']
)
async def export_events(request: Request):
events = await Event.objects.all()
data = [event.dict() for event in events]
return data_to_csv_response(data, 'events')
@app.get(
"/export/attendees",
tags=['export']
)
async def export_attendees(request: Request):
attendees = await Attendee.objects.all()
data = [attendee.dict() for attendee in attendees]
return data_to_csv_response(data, 'attendees')
@app.get(
"/export/claims",
tags=['export']
)
async def export_claims(request: Request):
claims = await Claim.objects.select_related('attendee').all()
data = []
for claim in claims:
data.append(dict(
id=claim.id,
event_id=claim.event.id,
reserved=claim.reserved,
link=claim.link,
attendee_username=claim.attendee.username if claim.attendee else ''
))
return data_to_csv_response(data, 'claims')
@app.get(
"/export/claims/{event_id}",
tags=['export']
)
async def export_claims_by_event(request: Request, event_id: str):
claims = await Claim.objects.select_related('attendee').filter(event__id__exact=event_id).all()
data = []
for claim in claims:
data.append(dict(
id=claim.id,
event_id=claim.event.id,
reserved=claim.reserved,
link=claim.link,
attendee_username=claim.attendee.username if claim.attendee else ''
))
return data_to_csv_response(data, f'claims-{event_id}')

0
poapbot/__init__.py Normal file
View File

54
poapbot/app.py Normal file
View File

@@ -0,0 +1,54 @@
from fastapi import FastAPI
import asyncio
import yaml
import logging
import asyncpraw
from poapbot.settings import RedditSettings, FastAPISettings
from poapbot.db import POAPDatabase
from poapbot.bot import RedditBot
from poapbot.scraper import RedditScraper
from poapbot.routers import admin, claim, event, export, scrape
SETTINGS = yaml.safe_load(open('settings.yaml', 'r'))
REDDIT_SETTINGS = RedditSettings.parse_obj(SETTINGS['reddit'])
API_SETTINGS = FastAPISettings.parse_obj(SETTINGS['fastapi'])
app = FastAPI(
title=API_SETTINGS.title,
version=API_SETTINGS.version,
openapi_tags=API_SETTINGS.openapi_tags
)
app.include_router(admin.router)
app.include_router(claim.router)
app.include_router(event.router)
app.include_router(export.router)
app.include_router(scrape.router)
logging.config.fileConfig('logging.conf', disable_existing_loggers=True)
logger = logging.getLogger(__name__)
@app.on_event('startup')
async def startup_event():
db = POAPDatabase()
await db.connect()
app.state.db = db
reddit_client = asyncpraw.Reddit(
username=REDDIT_SETTINGS.auth.username,
password=REDDIT_SETTINGS.auth.password.get_secret_value(),
client_id=REDDIT_SETTINGS.auth.client_id,
client_secret=REDDIT_SETTINGS.auth.client_secret.get_secret_value(),
user_agent=REDDIT_SETTINGS.auth.user_agent
)
bot = RedditBot(reddit_client, db)
asyncio.create_task(bot.run())
app.state.scraper = RedditScraper(reddit_client)
app.state.bot = bot
@app.on_event("shutdown")
async def shutdown():
await app.state.db.close()

View File

@@ -5,34 +5,43 @@ from datetime import datetime
import logging
import ormar
import re
import yaml
from poapbot.db.models import Event, Claim, Attendee, Admin, RequestMessage, ResponseMessage
from poapbot.settings import POAPSettings
from poapbot.db import POAPDatabase, DoesNotExist
from ..models import Event, Claim, Attendee, Admin, RequestMessage, ResponseMessage
from ..store import EventDataStore
from .exceptions import NotStartedEvent, ExpiredEvent, NoClaimsAvailable, InvalidCode, InsufficientAccountAge, InsufficientKarma, UnauthorizedCommand
logger = logging.getLogger(__name__)
SETTINGS = POAPSettings.parse_obj(yaml.safe_load(open('settings.yaml','r'))['poap'])
CREATE_EVENT_PATTERN = re.compile(r'create_event (?P<id>\w+) (?P<name>\w+) (?P<code>\w+) (?P<start_date>[\w:-]+) (?P<expiry_date>[\w:-]+) (?P<minimum_age>\w+) (?P<minimum_karma>\w+)')
ADD_CLAIMS_PATTERN = re.compile(r'add_claims (?P<event_id>\w+) (?P<codes>(\w+,?)+)')
class RedditBot:
def __init__(self, client: Reddit, store: EventDataStore):
def __init__(self, client: Reddit, db: POAPDatabase):
self.client = client
self.store = store
self.db = db
async def reserve_claim(self, code: str, redditor: Redditor) -> Claim:
event = await self.store.get(Event, code=code)
if not event:
try:
event = await self.db.get_event_by_code(code)
except DoesNotExist:
raise InvalidCode
elif not event.started():
try:
return await self.db.get_claim_by_event_username(event.id, redditor.name)
except DoesNotExist:
pass
if not event.started():
raise NotStartedEvent(event)
elif event.expired():
raise ExpiredEvent(event)
existing_claim = await self.store.get_filter(Claim, attendee__username=redditor.name, event__id__exact=event.id)
if existing_claim:
return existing_claim
await redditor.load()
age = (datetime.utcnow() - datetime.utcfromtimestamp(int(redditor.created_utc))).total_seconds() // 86400 # seconds in a day
if redditor.comment_karma + redditor.link_karma < event.minimum_karma:
@@ -40,16 +49,10 @@ class RedditBot:
elif age < event.minimum_age:
raise InsufficientAccountAge(event)
async with self.store.db.transaction():
try:
claim = await self.store.get_filter_first(Claim, reserved__exact=False, event__id__exact=event.id)
except ormar.exceptions.NoMatch:
raise NoClaimsAvailable(event)
attendee = await self.store.get_or_create(Attendee, username=redditor.name)
claim.attendee = attendee
claim.reserved = True
await claim.update()
return claim
try:
return await self.db.set_claim_by_event_id(event.id, redditor.name)
except DoesNotExist:
raise NoClaimsAvailable(event)
async def try_claim(self, code: str, message: Message, redditor: Redditor) -> Comment:
claim = None
@@ -80,7 +83,10 @@ class RedditBot:
return await message.reply(f'Bot encountered an unrecognized error :(')
async def is_admin(self, redditor: Redditor):
return await self.store.get(Admin, username__exact=redditor.name) is not None
try:
return await self.db.get_admin_by_username(redditor.name) is not None
except DoesNotExist:
return False
async def create_event(self, message: Message, redditor: Redditor) -> Comment:
if not await self.is_admin(redditor):
@@ -98,13 +104,13 @@ class RedditBot:
else:
command_data = command_data.groupdict()
existing_event = await self.store.get(Event, pk=command_data['id'])
existing_event = await self.db.get_event_by_id(command_data['id'])
if existing_event:
logger.debug(f'Received request to create event, but an event with the provided id already exists')
return await message.reply(f'Failed to create event: An event with id {command_data["id"]} already exists')
try:
event = await self.store.create(Event, **command_data)
event = await self.db.create_event(**command_data)
logger.debug('Received request to create event, successful')
return await message.reply(f'Successfully created event {event.name}')
except Exception as e:
@@ -130,19 +136,18 @@ class RedditBot:
logger.info('Received message from reddit, skipping')
return
request_message = await self.store.get(RequestMessage, secondary_id=message.id)
request_message = await self.db.get(RequestMessage, secondary_id=message.id)
if request_message:
logger.debug(f'Request message {request_message.secondary_id} has already been processed, skipping')
await message.mark_read()
return
request_message = await self.store.create(
RequestMessage,
secondary_id=message.id,
username=redditor.name,
created=message.created_utc,
subject=message.subject,
body=message.body
await self.db.create_request_message(
message.id,
redditor.name,
message.created_utc,
message.subject,
message.body
)
if code == 'create_event':
@@ -151,12 +156,11 @@ class RedditBot:
comment = await self.try_claim(code, message, redditor)
await message.mark_read()
await self.store.create(
ResponseMessage,
secondary_id=comment.id,
username=comment.author.name,
created=comment.created_utc,
body=comment.body
await self.db.create_response_message(
comment.id,
comment.author.name,
comment.created_utc,
comment.body
)
async def run(self):

View File

@@ -1,4 +1,4 @@
from ..models import Event
from poapbot.db.models import Event
from asyncpraw.models import Redditor
class NotStartedEvent(Exception):

13
poapbot/db/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
from databases import Database
import sqlalchemy
import yaml
from poapbot.settings import DBSettings
SETTINGS = yaml.safe_load(open('settings.yaml', 'r'))
DB_SETTINGS = DBSettings.parse_obj(SETTINGS['db'])
metadata = sqlalchemy.MetaData()
database = Database(DB_SETTINGS.url)
from .database import POAPDatabase
from .exceptions import *

290
poapbot/db/database.py Normal file
View File

@@ -0,0 +1,290 @@
from poapbot.db.models import attendee
from ormar import Model
from ormar.exceptions import NoMatch
from .exceptions import BulkError, DoesNotExist, ConflictError, BulkError
from .models import *
from . import database
from datetime import datetime
from typing import List, Union, Dict, Tuple
class POAPDatabase:
def __init__(self):
self.db = database
async def connect(self):
if not self.db.is_connected:
await self.db.connect()
async def close(self):
if self.db.is_connected:
await self.db.disconnect()
## Claims
async def create_claim(self, claim: ClaimCreate) -> Claim:
event = await self.get_event_by_id(claim.event_id)
if claim.username:
try:
attendee = await self.get_attendee_by_username(claim.username)
except DoesNotExist:
attendee = await self.create_attendee_by_username(claim.username)
else:
attendee = None
db_claim = Claim(event=event, attendee=attendee, link=claim.link, reserved=True if attendee else False)
await db_claim.save()
return Claim.parse_obj(db_claim)
async def set_claim_by_id(self, id: str, username: str) -> Claim:
async with self.db.transaction():
claim = await self.get_claim_by_id(id)
try:
existing_claim = await self.get_claim_by_event_username(claim.event.id, username)
if existing_claim:
raise ConflictError(f'Username {username} already has claim for event {claim.event.id}: Claim {existing_claim.id}')
except DoesNotExist:
pass
attendee = await Attendee.objects.get_or_create(username=username)
claim.attendee = attendee
claim.reserved = True
await claim.update()
return claim
async def get_claim_by_id(self, id: str) -> Claim:
try:
return await Claim.objects.get(id=id)
except NoMatch as e:
raise DoesNotExist(f'Claim with id {id} does not exist') from e
async def get_claim_by_event_username(self, event_id: str, username: str) -> Claim:
try:
return await Claim.objects.filter(attendee__username__exact=username, event__id__exact=event_id).get()
except NoMatch as e:
raise DoesNotExist(f'Claim by username {username} for event {event_id} does not exist') from e
async def delete_claim_by_id(self, id: str) -> None:
claim = await self.get_claim_by_id(id)
await claim.delete()
async def clear_claim_by_id(self, id: str) -> Claim:
claim = await self.get_claim_by_id(id)
async with self.db.transaction():
claim.remove(claim.attendee, 'attendee')
claim.reserved = False
await claim.update()
async def set_claim_by_event_id(self, event_id: str, username: str) -> Claim:
async with self.db.transaction():
try:
existing_claim = await self.get_claim_by_event_username(event_id, username)
if existing_claim:
raise ConflictError(f'Username {username} already has claim for event {event_id}: Claim {existing_claim.id}')
except DoesNotExist:
pass
try:
claim = await Claim.objects.filter(reserved__exact=False, event__id__exact=event_id).first()
except NoMatch:
raise DoesNotExist(f'No claims available for event {event_id}')
try:
attendee = await self.get_attendee_by_username(username)
except DoesNotExist:
attendee = await self.create_attendee_by_username(username)
claim.attendee = attendee
claim.reserved = True
await claim.update()
return claim
async def get_claims(self, select_related: List[str] = None, offset: int = 0, limit: int = 0) -> List[Claim]:
try:
q = Claim.objects.offset(offset)
if select_related:
q = q.select_related(select_related)
if limit > 0:
q = q.limit(limit)
return await q.all()
except NoMatch:
return []
async def get_claims_by_event_id(self, event_id: str, select_related: List[str] = None, offset: int = 0, limit: int = 0) -> List[Claim]:
try:
q = Claim.objects.filter(event__code__id=event_id).offset(offset)
if select_related:
q = q.select_related(select_related)
if limit > 0:
q = q.limit(limit)
return await q.all()
except NoMatch:
return []
## Events
async def create_event(self, event: EventCreate) -> Event:
try:
event = await self.get_event_by_id(event.id)
if event:
raise ConflictError(f'Event with id {event.id} already exists')
except DoesNotExist:
event = Event(**event.dict())
await event.save()
return event
async def update_event(self, event_update: EventUpdate) -> Event:
event = await self.get_event_by_id(event_update.id)
await event.update(**event_update.dict(exclude_none=True))
return event
async def get_event_by_id(self, id: str) -> Event:
try:
return await Event.objects.get(id=id)
except NoMatch as e:
raise DoesNotExist(f'Event {id} does not exist') from e
async def get_event_by_code(self, code: str) -> Event:
try:
return await Event.objects.get(code__exact=code)
except NoMatch as e:
raise DoesNotExist(f'Event with code {code} does not exist') from e
async def get_events(self, offset: int = 0, limit: int = 0) -> List[Event]:
try:
q = Event.objects.offset(offset)
if limit > 0:
q = q.limit(limit)
return await q.all()
except NoMatch:
return []
async def delete_event_by_id(self, id: str) -> Event:
event = await self.get_event_by_id(id)
await event.delete()
return event
## Attendees
async def create_attendee_by_username(self, username: str) -> Attendee:
attendee = Attendee(username=username)
await attendee.save()
return attendee
async def get_attendee_by_id(self, id: str) -> Attendee:
try:
return await Attendee.objects.get(id=id)
except NoMatch as e:
raise DoesNotExist(f'Attendee with id {id} does not exist') from e
async def get_attendee_by_username(self, username: str) -> Attendee:
try:
return await Attendee.objects.get(username__exact=username)
except NoMatch as e:
raise DoesNotExist(f'Attendee with username {username} does not exist') from e
async def get_attendees(self, offset: int = 0, limit: int = 0) -> List[Attendee]:
try:
q = Attendee.objects.offset(offset)
if limit > 0:
q = q.limit(limit)
return await q.all()
except NoMatch:
return []
## Admins
async def create_admin(self, admin: AdminCreate) -> Admin:
try:
db_admin = await self.get_admin_by_username(admin.username)
if db_admin:
raise ConflictError(f'Admin with username {admin.username} already exists')
except DoesNotExist:
db_admin = Admin(**admin.dict())
await db_admin.save()
return db_admin
async def get_admin_by_id(self, id: str) -> Admin:
try:
return await Admin.objects.get(id=id)
except NoMatch as e:
raise DoesNotExist(f'Admin with id {id} does not exist') from e
async def get_admin_by_username(self, username: str) -> Admin:
try:
return await Admin.objects.get(username__exact=username)
except NoMatch as e:
raise DoesNotExist(f'Admin with username {username} does not exist') from e
## Request Messages
async def create_request_message(self, secondary_id: str, username: str, created: datetime, subject: str, body: str) -> RequestMessage:
request_message = RequestMessage(
secondary_id=secondary_id,
username=username,
created=created,
subject=subject,
body=body
)
await request_message.save()
return request_message
async def get_request_message_by_id(self, id: str) -> RequestMessage:
try:
return await RequestMessage.objects.get(id=id)
except NoMatch as e:
raise DoesNotExist(f'Request message with id {id} does not exist') from e
## Response Messages
async def create_response_message(self, secondary_id: str, username: str, created: datetime, body: str) -> ResponseMessage:
response_message = ResponseMessage(
secondary_id=secondary_id,
username=username,
created=created,
body=body
)
await response_message.save()
return response_message
async def get_response_message_by_id(self, id: str) -> ResponseMessage:
try:
return await ResponseMessage.objects.get(id=id)
except NoMatch as e:
raise DoesNotExist(f'Response message with id {id} does not exist') from e
## Bulk
async def create_claims_bulk(self, event_id: str, new_claims: List[ClaimCreate]) -> List[Claim]:
event = await self.get_event_by_id(event_id)
existing_claims = await self.get_claims_by_event_id(event_id)
existing_links = set([c.link for c in existing_claims])
existing_usernames = set([c.attendee.username for c in existing_claims if c.attendee])
errors = []
for index, claim in enumerate(new_claims):
if claim.username in existing_usernames:
errors.append({'index':index, 'reason':f'Username {claim.username} already has reserved claim'})
elif claim.link in existing_links:
errors.append({'index':index, 'reason':f'Claim link {claim.link} already exists'})
elif not claim.link:
errors.append({'index':index, 'reason':f'Invalid link {claim.link}'})
if errors:
raise BulkError(errors)
attendees = []
claims = []
for new_claim in new_claims:
if new_claim.username:
try:
attendee = await self.get_attendee_by_username(new_claim.username)
except DoesNotExist:
attendee = await self.create_attendee_by_username(new_claim.username)
attendees.append(attendee)
else:
attendee = None
claim = Claim(attendee=attendee, event=event, link=new_claim.link, reserved=True if attendee else False)
claims.append(claim)
async with self.db.transaction():
await Attendee.objects.bulk_create(attendees)
await Claim.objects.bulk_create(claims)
return claims

12
poapbot/db/exceptions.py Normal file
View File

@@ -0,0 +1,12 @@
from typing import List, Dict, Union
class DoesNotExist(Exception):
"""Raised when requested resource does not exist"""
class ConflictError(Exception):
"""Raised when a resource conflict occurs or a constraint is violated"""
class BulkError(Exception):
"""Raised when an error in encountered while processing bulk insert"""
def __init__(self, errors: List[Dict[Union[int,str], str]]):
self.errors = errors

View File

@@ -0,0 +1,29 @@
from ormar import ModelMeta
from poapbot.db import metadata, database
class BaseMeta(ModelMeta):
metadata = metadata
database = database
from .event import Event, EventCreate, EventUpdate
from .attendee import Attendee, AttendeeCreate
from .admin import Admin, AdminCreate
from .claim import Claim, ClaimCreate
from .message import RequestMessage, RequestMessageCreate, ResponseMessage, ResponseMessageCreate
__all__ = [
'Event',
'EventCreate',
'EventUpdate',
'Attendee',
'AttendeeCreate',
'Admin',
'AdminCreate',
'Claim',
'ClaimCreate',
'RequestMessage',
'RequestMessageCreate',
'ResponseMessage',
'ResponseMessageCreate'
]

View File

@@ -1,3 +1,4 @@
from pydantic import BaseModel
import ormar
from datetime import datetime
from typing import List, Optional
@@ -10,5 +11,9 @@ class Admin(ormar.Model):
tablename = "admins"
constraints = [ormar.UniqueColumns('username')]
id: str = ormar.Integer(primary_key=True)
username: str = ormar.String(max_length=100)
id: int = ormar.Integer(primary_key=True)
username: str = ormar.String(max_length=100)
class AdminCreate(BaseModel):
username: str

View File

@@ -1,9 +1,9 @@
from pydantic import BaseModel
import ormar
from datetime import datetime
from typing import List, Optional
from . import BaseMeta
from .event import Event
class Attendee(ormar.Model):
@@ -12,4 +12,8 @@ class Attendee(ormar.Model):
constraints = [ormar.UniqueColumns('username')]
id: str = ormar.Integer(primary_key=True)
username: str = ormar.String(max_length=100)
username: str = ormar.String(max_length=100)
class AttendeeCreate(BaseModel):
username: str

View File

@@ -1,3 +1,4 @@
from pydantic import BaseModel
import ormar
from datetime import datetime
from typing import List, Optional
@@ -16,4 +17,10 @@ class Claim(ormar.Model):
attendee: Attendee = ormar.ForeignKey(Attendee, nullable=True, skip_reverse=True)
event: Event = ormar.ForeignKey(Event, skip_reverse=True)
link: str = ormar.String(max_length=256)
reserved: Optional[bool] = ormar.Boolean(default=False)
reserved: Optional[bool] = ormar.Boolean(default=False)
class ClaimCreate(BaseModel):
username: str = None
event_id: str
link: str

View File

@@ -1,3 +1,4 @@
from pydantic import BaseModel
import ormar
from pydantic import BaseModel
from datetime import datetime
@@ -24,4 +25,28 @@ class Event(ormar.Model):
return self.expiry_date < datetime.utcnow()
def started(self):
return self.start_date < datetime.utcnow()
return self.start_date < datetime.utcnow()
class EventCreate(BaseModel):
id: str
name: str
description: str = ""
code: str
start_date: datetime
expiry_date: datetime
minimum_karma: int = 0
minimum_age: int = 0
class EventUpdate(BaseModel):
id: str
name: Optional[str] = None
description: Optional[str] = None
code: Optional[str] = None
start_date: Optional[datetime] = None
expiry_date: Optional[datetime] = None
minimum_karma: Optional[int] = None
minimum_age: Optional[int] = None

View File

@@ -1,9 +1,9 @@
from pydantic import BaseModel
import ormar
from datetime import datetime
from typing import List, Optional
from . import BaseMeta
from .claim import Claim
class RequestMessage(ormar.Model):
@@ -26,4 +26,19 @@ class ResponseMessage(ormar.Model):
secondary_id: str = ormar.String(max_length=100)
username: str = ormar.String(max_length=100)
created: datetime = ormar.DateTime()
body: str = ormar.String(max_length=1024)
body: str = ormar.String(max_length=1024)
class RequestMessageCreate(BaseModel):
secondary_id: str
username: str
created: datetime
subject: str
body: str
class ResponseMessageCreate(BaseModel):
secondary_id: str
username: str
created: datetime
body: str

9
poapbot/dependencies.py Normal file
View File

@@ -0,0 +1,9 @@
from fastapi import Request
from poapbot.db import POAPDatabase
from poapbot.scraper import RedditScraper
async def get_db(request: Request) -> POAPDatabase:
return request.app.state.db
async def get_scraper(request: Request) -> RedditScraper:
return request.app.state.scraper

View File

@@ -1,23 +0,0 @@
import ormar
import sqlalchemy
import databases
from datetime import datetime
import yaml
from .settings import DBSettings
SETTINGS = yaml.safe_load(open('settings.yaml', 'r'))
DB_SETTINGS = DBSettings.parse_obj(SETTINGS['db'])
metadata = sqlalchemy.MetaData()
database = databases.Database(DB_SETTINGS.url)
class BaseMeta(ormar.ModelMeta):
metadata = metadata
database = database
from .event import Event
from .attendee import Attendee
from .admin import Admin
from .claim import Claim
from .message import RequestMessage, ResponseMessage

View File

@@ -1,3 +0,0 @@
from .reddit import RedditSettings
from .db import DBSettings
from .fastapi import FastAPISettings

View File

25
poapbot/routers/admin.py Normal file
View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime
from typing import Optional
from poapbot.db import POAPDatabase, ConflictError
from poapbot.db.models import Admin, AdminCreate
from ..dependencies import get_db
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(get_db)]
)
@router.post(
"/create_admin",
description="Create Admin",
tags=['admin'],
response_model=Admin
)
async def create_admin(admin: AdminCreate, db: POAPDatabase = Depends(get_db)):
try:
return await db.create_admin(admin)
except ConflictError as e:
raise HTTPException(status_code=409, detail=str(e))

110
poapbot/routers/claim.py Normal file
View File

@@ -0,0 +1,110 @@
from poapbot.db.models.claim import ClaimCreate
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from datetime import datetime
from typing import Optional
import pandas as pd
from io import StringIO
from poapbot.db import POAPDatabase, DoesNotExist, ConflictError, BulkError
from poapbot.db.models import Claim, Event
from ..dependencies import get_db
router = APIRouter(
prefix="/claims",
tags=["claims"],
dependencies=[Depends(get_db)]
)
@router.post(
"/upload_claims",
tags=['claims']
)
async def upload_claims(event_id: str, file: UploadFile = File(...), db: POAPDatabase = Depends(get_db)):
if file.content_type != 'text/csv':
raise HTTPException(status_code=415, detail=f'File must be of type /text/csv, provided: {file.content_type}')
try:
content = await file.read()
df = pd.read_csv(StringIO(content.decode()))
except Exception as e:
raise HTTPException(status_code=500, detail=f'Failed to parse file: {e}')
if 'link' not in df.columns:
raise HTTPException(status_code=400, detail=f'List must have "link" column header')
if 'username' not in df.columns:
df['username'] = None
claims = [ClaimCreate(username=row.username, event_id=event_id, link=row.link) for ix, row in df.iterrows()]
try:
claims = await db.create_claims_bulk(event_id, claims)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
except BulkError as e:
raise HTTPException(status_code=400, detail={'count':len(e.errors), 'errors':e.errors[:100]})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/{id}",
tags=['claims'],
response_model=Claim
)
async def get_claim_by_id(id: str, db: POAPDatabase = Depends(get_db)):
try:
return await db.get_claim_by_id(id)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post(
"/",
tags=['claims']
)
async def create_claim(event_id: str, link: str, username: str = None, db: POAPDatabase = Depends(get_db)):
try:
return await db.create_claim(event_id, link, username)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete(
"/{id}",
tags=['claims']
)
async def delete_claim(id: str, db: POAPDatabase = Depends(get_db)):
try:
await db.delete_claim_by_id(id)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put(
"/{id}/clear_attendee",
tags=['claims']
)
async def clear_claim_attendee(id: str, db: POAPDatabase = Depends(get_db)):
try:
return await db.clear_claim_by_id(id)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put(
"/{id}/reserve",
tags=['claims']
)
async def set_claim_by_id(id: str, username: str, db: POAPDatabase = Depends(get_db)):
try:
return await db.set_claim_by_id(id, username)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
except ConflictError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.put(
"/reserve",
tags=['claims']
)
async def set_claim_by_event_id(event_id: str, username: str, db: POAPDatabase = Depends(get_db)):
try:
return await db.set_claim_by_event_id(event_id, username)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
except ConflictError as e:
raise HTTPException(status_code=409, detail=str(e))

43
poapbot/routers/event.py Normal file
View File

@@ -0,0 +1,43 @@
from poapbot.db.exceptions import ConflictError, DoesNotExist
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime
from typing import Optional
from poapbot.db import POAPDatabase
from poapbot.db.models import Event, EventCreate, EventUpdate
from ..dependencies import get_db
router = APIRouter(
prefix="/events",
tags=["events"],
dependencies=[Depends(get_db)]
)
@router.post("/", description="Create Event", response_model=Event)
async def create_event(event: EventCreate, db: POAPDatabase = Depends(get_db)):
try:
return await db.create_event(event)
except ConflictError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.get("/{id}", response_model=Event)
async def get_event_by_id(id: str, db: POAPDatabase = Depends(get_db)):
try:
return await db.get_event_by_id(id)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{id}", response_model=Event)
async def update_event(event: EventUpdate, db: POAPDatabase = Depends(get_db)):
try:
return await db.update_event(event)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{id}", response_model=Event)
async def delete_event(id: str, db: POAPDatabase = Depends(get_db)):
try:
return await db.delete_event_by_id(id)
except DoesNotExist as e:
raise HTTPException(status_code=404, detail=str(e))

62
poapbot/routers/export.py Normal file
View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from datetime import datetime
from typing import Optional
from io import StringIO
import pandas as pd
from poapbot.db import POAPDatabase
from poapbot.db.models import Claim, Event
from ..dependencies import get_db
router = APIRouter(
prefix="/export",
tags=["export"],
dependencies=[Depends(get_db)]
)
def data_to_csv_response(data, filename):
stream = StringIO(pd.DataFrame(data).fillna('').to_csv(index=False))
response = StreamingResponse(stream, media_type="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename={filename}-{datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S')}.csv"
return response
@router.get("/events")
async def export_events(db: POAPDatabase = Depends(get_db)):
events = await db.get_events()
data = [event.dict() for event in events]
return data_to_csv_response(data, 'events')
@router.get("/attendees")
async def export_attendees(db: POAPDatabase = Depends(get_db)):
attendees = await db.get_attendees()
data = [attendee.dict() for attendee in attendees]
return data_to_csv_response(data, 'attendees')
@router.get("/claims")
async def export_claims(db: POAPDatabase = Depends(get_db)):
claims = await db.get_claims(select_related=['attendee'])
data = []
for claim in claims:
data.append(dict(
id=claim.id,
event_id=claim.event.id,
reserved=claim.reserved,
link=claim.link,
username=claim.attendee.username if claim.attendee else ''
))
return data_to_csv_response(data, 'claims')
@router.get("/claims/{event_id}")
async def export_claims_by_event(event_id: str, db: POAPDatabase = Depends(get_db)):
claims = await db.get_claims_by_event_id(event_id, select_related=['attendee'])
data = []
for claim in claims:
data.append(dict(
id=claim.id,
event_id=claim.event.id,
reserved=claim.reserved,
link=claim.link,
username=claim.attendee.username if claim.attendee else ''
))
return data_to_csv_response(data, f'claims-{event_id}')

30
poapbot/routers/scrape.py Normal file
View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime
from typing import Optional
from poapbot.scraper import RedditScraper
from ..dependencies import get_scraper
router = APIRouter(
prefix="/scrape",
tags=["scrape"],
dependencies=[Depends(get_scraper)]
)
@router.get("/get_usernames_by_submission")
async def get_usernames_by_submission(submission_id: str, traverse: bool = False, scraper: RedditScraper = Depends(get_scraper)):
try:
comments = await scraper.get_comments_by_submission_id(submission_id, traverse)
return list(set([c.author.name for c in comments if c.author]))
except Exception as e:
# todo better exception handling
raise HTTPException(status_code=500, detail=str(e))
@router.get("/get_usernames_by_comment")
async def get_usernames_by_comment(comment_id: str, traverse: bool = False, scraper: RedditScraper = Depends(get_scraper)):
try:
comments = await scraper.get_comments_by_comment_id(comment_id, traverse)
return list(set([c.author.name for c in comments if c.author]))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,4 @@
from .reddit import RedditSettings
from .db import DBSettings
from .fastapi import FastAPISettings
from .poap import POAPSettings

18
poapbot/settings/poap.py Normal file
View File

@@ -0,0 +1,18 @@
from pydantic import BaseSettings, SecretStr
from pydantic.env_settings import SettingsSourceCallable
from typing import Tuple
class POAPSettings(BaseSettings):
url: str
class Config:
env_prefix = 'poap_'
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> Tuple[SettingsSourceCallable, ...]:
return env_settings, init_settings, file_secret_settings

View File

@@ -1,25 +0,0 @@
from databases import Database
from ormar import Model
class EventDataStore:
def __init__(self, db: Database):
self.db = db
async def get(self, cls: Model, *args, **kwargs):
return await cls.objects.get_or_none(*args, **kwargs)
async def get_filter(self, cls: Model, *args, **kwargs):
return await cls.objects.filter(*args, **kwargs).get_or_none()
async def get_filter_first(self, cls: Model, *args, **kwargs):
return await cls.objects.filter(*args, **kwargs).first()
async def get_or_create(self, cls: Model, *args, **kwargs):
return await cls.objects.get_or_create(*args, **kwargs)
async def create(self, cls: Model, *args, **kwargs):
obj = cls(*args, **kwargs)
await obj.save()
return obj

View File

@@ -1,5 +1,6 @@
pydantic==1.8
fastapi==0.63.0
databases
uvicorn[standard]
asyncpraw==7.2.0
pyyaml==5.4.1

View File

@@ -1,39 +1,42 @@
import pytest
import asyncio
from datetime import datetime
from poapbot.db import DoesNotExist
from poapbot.bot import RedditBot
from poapbot.bot.exceptions import NotStartedEvent, ExpiredEvent, NoClaimsAvailable, InvalidCode, InsufficientAccountAge, InsufficientKarma
@pytest.fixture
def bot(store):
return RedditBot(None, store)
def bot(db):
return RedditBot(None, db)
@pytest.mark.asyncio
async def test_reserve_claim_no_event(mocker, bot, dummy_event, dummy_redditor):
mocker.patch.object(bot.store, 'get', return_value=None)
mocker.patch.object(bot.db, 'get_event_by_code', side_effect=DoesNotExist)
with pytest.raises(InvalidCode):
await bot.reserve_claim('invalid_code', dummy_redditor)
@pytest.mark.asyncio
async def test_reserve_claim_event_not_started(mocker, bot, dummy_event, dummy_redditor):
dummy_event.start_date = datetime(2100, 1, 1)
mocker.patch.object(bot.store, 'get', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_event_by_code', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_claim_by_event_username', side_effect=DoesNotExist)
with pytest.raises(NotStartedEvent):
await bot.reserve_claim('test', dummy_redditor)
await bot.reserve_claim(dummy_event.code, dummy_redditor)
@pytest.mark.asyncio
async def test_reserve_claim_event_expired(mocker, bot, dummy_event, dummy_redditor):
dummy_event.expiry_date = datetime(1969, 1, 1)
mocker.patch.object(bot.store, 'get', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_event_by_code', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_claim_by_event_username', side_effect=DoesNotExist)
with pytest.raises(ExpiredEvent):
await bot.reserve_claim('test', dummy_redditor)
await bot.reserve_claim(dummy_event.code, dummy_redditor)
@pytest.mark.asyncio
async def test_reserve_claim_existing_claim(mocker, bot, dummy_event, dummy_redditor, dummy_claim_reserved):
mocker.patch.object(bot.store, 'get', return_value=dummy_event)
mocker.patch.object(bot.store, 'get_filter', return_value=dummy_claim_reserved)
mocker.patch.object(bot.db, 'get_event_by_code', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_claim_by_event_username', return_value=dummy_claim_reserved)
claim = await bot.reserve_claim('test', dummy_redditor)
claim = await bot.reserve_claim(dummy_event.code, dummy_redditor)
assert claim == dummy_claim_reserved
assert claim.reserved
@@ -42,16 +45,16 @@ async def test_reserve_claim_insufficient_karma(mocker, bot, dummy_event, dummy_
dummy_redditor.comment_karma = 0
dummy_redditor.link_karma = 0
dummy_event.minimum_karma = 1
mocker.patch.object(bot.store, 'get', return_value=dummy_event)
mocker.patch.object(bot.store, 'get_filter', return_value=None)
mocker.patch.object(bot.db, 'get_event_by_code', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_claim_by_event_username', side_effect=DoesNotExist)
with pytest.raises(InsufficientKarma):
await bot.reserve_claim('test', dummy_redditor)
await bot.reserve_claim(dummy_event.code, dummy_redditor)
@pytest.mark.asyncio
async def test_reserve_claim_insufficient_age(mocker, bot, dummy_event, dummy_redditor):
dummy_redditor.created_utc = datetime.utcnow().timestamp()
dummy_event.minimum_age = 1
mocker.patch.object(bot.store, 'get', return_value=dummy_event)
mocker.patch.object(bot.store, 'get_filter', return_value=None)
mocker.patch.object(bot.db, 'get_event_by_code', return_value=dummy_event)
mocker.patch.object(bot.db, 'get_claim_by_event_username', side_effect=DoesNotExist)
with pytest.raises(InsufficientAccountAge):
await bot.reserve_claim('test', dummy_redditor)
await bot.reserve_claim(dummy_event.code, dummy_redditor)

View File

@@ -1,6 +1,6 @@
import pytest
from fixtures.store import store
from fixtures.db import db
from fixtures.event import dummy_event
from fixtures.attendee import dummy_attendee
from fixtures.claim import dummy_claim, dummy_claim_reserved

View File

@@ -1,5 +1,5 @@
import pytest
from poapbot.models import Attendee
from poapbot.db.models import Attendee
from datetime import datetime
@pytest.fixture

View File

@@ -1,5 +1,5 @@
import pytest
from poapbot.models import Claim
from poapbot.db.models import Claim
@pytest.fixture
def dummy_claim(dummy_event):

6
tests/fixtures/db.py vendored Normal file
View File

@@ -0,0 +1,6 @@
import pytest
from poapbot.db import POAPDatabase
@pytest.fixture(autouse=True)
def db():
return POAPDatabase()

View File

@@ -1,5 +1,5 @@
import pytest
from poapbot.models import Event
from poapbot.db.models import Event
from datetime import datetime
@pytest.fixture

View File

@@ -1,6 +0,0 @@
import pytest
from poapbot.store import EventDataStore
@pytest.fixture(autouse=True)
def store():
return EventDataStore(None)