From 21c78ccce50bee34df1a8475ec4907a7189a538e Mon Sep 17 00:00:00 2001 From: Brosef Date: Sat, 7 Mar 2026 17:45:42 +0000 Subject: [PATCH] Implemented backend for #4 --- backend.py | 2 + blueprints/booper.py | 237 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 blueprints/booper.py diff --git a/backend.py b/backend.py index 033db6c..d4e90c2 100644 --- a/backend.py +++ b/backend.py @@ -12,6 +12,7 @@ import flask import db from blueprints.projects import projectsBP +from blueprints.booper import booperBP from blueprints.static import staticBP logging.basicConfig(level=logging.DEBUG) @@ -21,6 +22,7 @@ dotenv.load_dotenv() app = flask.Flask(__name__) db.init() app.register_blueprint(projectsBP) +app.register_blueprint(booperBP) app.register_blueprint(staticBP) def onClose(): diff --git a/blueprints/booper.py b/blueprints/booper.py new file mode 100644 index 0000000..98665de --- /dev/null +++ b/blueprints/booper.py @@ -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/', 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)}