Implemented backend for #4
This commit is contained in:
@ -12,6 +12,7 @@ import flask
|
|||||||
import db
|
import db
|
||||||
|
|
||||||
from blueprints.projects import projectsBP
|
from blueprints.projects import projectsBP
|
||||||
|
from blueprints.booper import booperBP
|
||||||
from blueprints.static import staticBP
|
from blueprints.static import staticBP
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
@ -21,6 +22,7 @@ dotenv.load_dotenv()
|
|||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
db.init()
|
db.init()
|
||||||
app.register_blueprint(projectsBP)
|
app.register_blueprint(projectsBP)
|
||||||
|
app.register_blueprint(booperBP)
|
||||||
app.register_blueprint(staticBP)
|
app.register_blueprint(staticBP)
|
||||||
|
|
||||||
def onClose():
|
def onClose():
|
||||||
|
|||||||
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