Implemented backend for #4
This commit is contained in:
237
blueprints/booper.py
Normal file
237
blueprints/booper.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
This blueprint deals with everything under /api/boop/.
|
||||
|
||||
Used environment variables:
|
||||
BOOPER_TARGETS: A list of comma (`,`) separated names for valid booper targets.
|
||||
These names are stripped to only contain alphanumeric characters.
|
||||
BOOPER_PRECISION: An integer (default 60) to define how precise the booper is.
|
||||
Shorter values are more precise, but take up more space in the database.
|
||||
"""
|
||||
|
||||
from threading import Thread
|
||||
import logging
|
||||
import string
|
||||
import time
|
||||
import os
|
||||
|
||||
import flask
|
||||
|
||||
import db
|
||||
|
||||
_log = logging.getLogger('blueprints.booper')
|
||||
|
||||
def stripName(name: str) -> str:
|
||||
"""
|
||||
Strips a name to only include ascii characters & digits.
|
||||
"""
|
||||
|
||||
return ''.join([c for c in name if c in string.ascii_letters+string.digits])
|
||||
|
||||
class Blueprint(flask.Blueprint):
|
||||
"""
|
||||
Holds all relavent data for this blueprint to function as well as interacting
|
||||
directly with the database to store and load data live.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._currentBoops = {} # Holds all boops in the current precision
|
||||
self._totalBoops = {} # Holds full boop totals
|
||||
self._boops24h = {} # Holds all boops that happened in the past 24h
|
||||
# The format of this is as follows:
|
||||
# {'name': [{'ts': ts, 'count': count}, {...}], ...}
|
||||
self.precision = int(os.getenv('BOOPER_PRECISION', '60'))
|
||||
self._currentTS = 0
|
||||
self._runWorker = True
|
||||
self._workerThread = None
|
||||
|
||||
# Load and filter names from `BOOPER_TARGETS` environment variable.
|
||||
for name in os.getenv('BOOPER_TARGETS', '').split(','):
|
||||
if name == '':
|
||||
continue # Ignore empty names
|
||||
name = stripName(name)
|
||||
self._currentBoops.update({name: 0})
|
||||
self._totalBoops.update({name: 0})
|
||||
|
||||
# Throw a warning if no names are detected
|
||||
if not self._currentBoops:
|
||||
_log.warning('No names are configured. This blueprint will not function.')
|
||||
_log.warning('Please configure BOOPER_TARGETS in your .env file.')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _worker(self):
|
||||
"""
|
||||
This function gets ran as a thread and it handles all time splitting capabilities.
|
||||
"""
|
||||
|
||||
while self._runWorker:
|
||||
# Check if we've past the precision threshold
|
||||
if time.time() - self._currentTS >= self.precision:
|
||||
_log.debug('Threshold reached.')
|
||||
# Save everything to the database
|
||||
self._save()
|
||||
# Reset variables
|
||||
for name in self._currentBoops:
|
||||
self._currentBoops[name] = 0
|
||||
self._currentTS = round(time.time())
|
||||
# Purge old data from 24h
|
||||
self._boops24h = {name: [data for data in self._boops24h[name]
|
||||
if time.time() - data['ts'] < 86400]
|
||||
for name in self._boops24h}
|
||||
time.sleep(1)
|
||||
|
||||
def _save(self):
|
||||
"""
|
||||
Saves everything to the database.
|
||||
"""
|
||||
|
||||
for name, count in self._currentBoops.items():
|
||||
# Check if this start time exists already
|
||||
if db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND startTime=?',
|
||||
(name, self._currentTS)).fetchone() is None:
|
||||
# If not, create a new row
|
||||
db.GLOBAL.execute('INSERT INTO booper (name, startTime, count) VALUES (?, ?, ?)',
|
||||
(name, self._currentTS, count))
|
||||
else:
|
||||
# If it does, edit the existing row
|
||||
db.GLOBAL.execute('UPDATE booper SET count=? WHERE name=? AND startTime=?',
|
||||
(count, name, self._currentTS))
|
||||
|
||||
# Save the new totals
|
||||
db.GLOBAL.execute('UPDATE booper SET count=? WHERE name=? AND total=1',
|
||||
(self._totalBoops[name], name))
|
||||
|
||||
# Commit everything
|
||||
db.GLOBAL.commit()
|
||||
|
||||
def boop(self, name: str) -> [int, int]:
|
||||
"""
|
||||
Boops `name`.
|
||||
"""
|
||||
|
||||
self._currentBoops[name] += 1
|
||||
self._totalBoops[name] += 1
|
||||
self._boops24h[name][-1]['count'] += 1
|
||||
|
||||
# Override the `register` function from `flask.Blueprint`
|
||||
# so we can initialise the database at an appropriate time.
|
||||
def register(self, *args, **kwargs):
|
||||
super().register(*args, **kwargs)
|
||||
|
||||
# Initialise the database
|
||||
db.GLOBAL.createTable('booper')
|
||||
db.GLOBAL.createColumn('booper', 'name STRING')
|
||||
db.GLOBAL.createColumn('booper', 'total BOOLEAN DEFAULT false')
|
||||
db.GLOBAL.createColumn('booper', 'startTime INTEGER')
|
||||
db.GLOBAL.createColumn('booper', 'count INTEGER DEFAULT 0')
|
||||
|
||||
for name in self._currentBoops:
|
||||
# Get the latest data for this name
|
||||
latestData = db.GLOBAL.execute('SELECT * FROM booper WHERE name=?'+
|
||||
'ORDER BY id DESC LIMIT 1', (name,)).fetchone()
|
||||
# If there is data,
|
||||
if latestData is not None:
|
||||
# Set the current time to the latest time known
|
||||
startTime = latestData['startTime']
|
||||
if startTime is not None and startTime > self._currentTS:
|
||||
self._currentTS = startTime
|
||||
# Update the current boop counter
|
||||
self._currentBoops[name] = latestData['count']
|
||||
# Otherwise,
|
||||
else:
|
||||
# Set the current timestamp to now
|
||||
self._currentTS = round(time.time())
|
||||
|
||||
# Check for total row
|
||||
if db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND total=1',
|
||||
(name,)).fetchone() is None:
|
||||
# If none is found, create one
|
||||
_log.debug('Creating total counter for "%s"...', name)
|
||||
db.GLOBAL.addRow('booper',
|
||||
{'name': name, 'startTime': round(time.time()), 'total': True})
|
||||
self._totalBoops[name] = 0
|
||||
else:
|
||||
# If one is found, load the data and set the totals
|
||||
totalData = db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND total=1 ',
|
||||
(name,)).fetchone()
|
||||
self._totalBoops[name] = totalData['count']
|
||||
|
||||
# Load all boops from the past 24h
|
||||
boops24h = db.GLOBAL.execute('SELECT * FROM booper WHERE total=0 AND startTime>=?',
|
||||
(time.time()-86400,)).fetchall()
|
||||
for row in boops24h:
|
||||
name = row['name']
|
||||
count = row['count']
|
||||
ts = row['startTime']
|
||||
|
||||
if name not in self._boops24h:
|
||||
self._boops24h.update({name: []})
|
||||
|
||||
self._boops24h[name].append({'ts': ts, 'count': count})
|
||||
|
||||
self._workerThread = Thread(target=self._worker)
|
||||
self._workerThread.start()
|
||||
|
||||
def getTotalBoops(self, name: str) -> int:
|
||||
"""
|
||||
Returns the total number of boops the user has recieved.
|
||||
"""
|
||||
|
||||
if name not in self.registeredNames:
|
||||
raise ValueError(f'Name "{name}" is not registered.')
|
||||
|
||||
return self._totalBoops[name]
|
||||
|
||||
def get24hBoops(self, name):
|
||||
"""
|
||||
Calculates how many boops `name` has received in the past 24h.
|
||||
"""
|
||||
|
||||
if name not in self.registeredNames:
|
||||
raise ValueError(f'Name "{name}" is not registered.')
|
||||
|
||||
return sum(data['count'] for data in self._boops24h[name])
|
||||
|
||||
def onClose(self):
|
||||
"""
|
||||
Safely saves and closes the booper.
|
||||
Automatically ran by the backend.
|
||||
"""
|
||||
|
||||
_log.info('Stopping worker...')
|
||||
self._runWorker = False
|
||||
self._workerThread.join()
|
||||
_log.info('Saving to database...')
|
||||
self._save()
|
||||
_log.info('Booper closed successfully.')
|
||||
|
||||
@property
|
||||
def registeredNames(self) -> list[str]:
|
||||
"""
|
||||
Returns a list of all registered names.
|
||||
"""
|
||||
|
||||
return list(self._currentBoops.keys())
|
||||
|
||||
booperBP = Blueprint('booperAPI', __name__)
|
||||
|
||||
@booperBP.route('/api/boop/<name>', methods=['GET', 'POST'], strict_slashes=False)
|
||||
def boops(name: str):
|
||||
"""
|
||||
Handles both GETting and POSTing boops through the boop API endpoint.
|
||||
"""
|
||||
|
||||
name = stripName(name)
|
||||
|
||||
if name not in booperBP.registeredNames:
|
||||
return {'success': False, 'message': f'Name "{name}" is not configured as a target.'}, 404
|
||||
|
||||
if flask.request.method == 'POST':
|
||||
booperBP.boop(name)
|
||||
|
||||
# Update the global counter
|
||||
#db.GLOBAL.execute('UPDATE booper SET count=? WHERE id=1', (booperBP.boops,))
|
||||
|
||||
return {'success': True,
|
||||
'total': booperBP.getTotalBoops(name),
|
||||
'24h': booperBP.get24hBoops(name)}
|
||||
Reference in New Issue
Block a user