mirror of
https://github.com/AtHeartEngineer/poap-reddit-bot.git
synced 2026-01-09 04:28:08 -05:00
huge refactor
This commit is contained in:
379
app.py
379
app.py
@@ -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
0
poapbot/__init__.py
Normal file
54
poapbot/app.py
Normal file
54
poapbot/app.py
Normal 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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
13
poapbot/db/__init__.py
Normal 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
290
poapbot/db/database.py
Normal 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
12
poapbot/db/exceptions.py
Normal 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
|
||||
29
poapbot/db/models/__init__.py
Normal file
29
poapbot/db/models/__init__.py
Normal 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'
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
9
poapbot/dependencies.py
Normal 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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
from .reddit import RedditSettings
|
||||
from .db import DBSettings
|
||||
from .fastapi import FastAPISettings
|
||||
0
poapbot/routers/__init__.py
Normal file
0
poapbot/routers/__init__.py
Normal file
25
poapbot/routers/admin.py
Normal file
25
poapbot/routers/admin.py
Normal 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
110
poapbot/routers/claim.py
Normal 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
43
poapbot/routers/event.py
Normal 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
62
poapbot/routers/export.py
Normal 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
30
poapbot/routers/scrape.py
Normal 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))
|
||||
4
poapbot/settings/__init__.py
Normal file
4
poapbot/settings/__init__.py
Normal 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
18
poapbot/settings/poap.py
Normal 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
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
pydantic==1.8
|
||||
fastapi==0.63.0
|
||||
databases
|
||||
uvicorn[standard]
|
||||
asyncpraw==7.2.0
|
||||
pyyaml==5.4.1
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
2
tests/fixtures/attendee.py
vendored
2
tests/fixtures/attendee.py
vendored
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from poapbot.models import Attendee
|
||||
from poapbot.db.models import Attendee
|
||||
from datetime import datetime
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
2
tests/fixtures/claim.py
vendored
2
tests/fixtures/claim.py
vendored
@@ -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
6
tests/fixtures/db.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import pytest
|
||||
from poapbot.db import POAPDatabase
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def db():
|
||||
return POAPDatabase()
|
||||
2
tests/fixtures/event.py
vendored
2
tests/fixtures/event.py
vendored
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from poapbot.models import Event
|
||||
from poapbot.db.models import Event
|
||||
from datetime import datetime
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
6
tests/fixtures/store.py
vendored
6
tests/fixtures/store.py
vendored
@@ -1,6 +0,0 @@
|
||||
import pytest
|
||||
from poapbot.store import EventDataStore
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store():
|
||||
return EventDataStore(None)
|
||||
Reference in New Issue
Block a user