diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fe7e49 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Environment variables for py_taplist +# Copy this file to .env and set your actual values + +# Your Brewers Friend API key (required) +BREWERS_FRIEND_API_KEY=your_api_key_here + +# Flask secret key for session management (optional, will use default if not set) +FLASK_SECRET_KEY=your_secret_key_here \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d8cd48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.venv +venv/ \ No newline at end of file diff --git a/README.md b/README.md index f44e8b6..e146526 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,69 @@ -You will need to install a few required libraries +## Taplist Application +A Flask-based application to display beer taplist data from Brewers Friend API with background polling for improved performance. - pip install requests flask +### Features +- **Secure API Key Management**: API keys are stored as environment variables, not in config files +- **Background Polling**: Data is fetched in the background at configurable intervals, making the UI fast and responsive +- **Admin Authentication**: Admin panel is protected with password authentication +- **Data Caching**: Beer data is cached in memory and persisted to XML files +- **Multiple Themes**: Support for different themes and styles -Run python3 taplist.py +### Installation -navigate to localhost:5000 on your favorite broswer. +You will need to install a few required libraries: -No need to edit any config files as its all down in the admin section of the site. Accessed via the gear in the top left corner. +```bash +pip install requests flask +``` +### Configuration +1. **Set Environment Variables** (Recommended): + ```bash + export BREWERS_FRIEND_API_KEY="your_api_key_here" + export FLASK_SECRET_KEY="your_secret_key_here" # Optional, defaults to a basic key + ``` -TODO --Would like to add a password to the admin section --add more themes --clean up code --clean up html and css +2. **Admin Password**: The default admin password is "admin". Change it via the admin panel after first login. + +3. **Background Polling**: Data is automatically fetched from the API in the background every hour (3600 seconds) by default. This can be configured in the admin panel. + +### Running the Application + +```bash +python3 taplist.py +``` + +Navigate to `localhost:5000` in your favorite browser. + +### Admin Access + +- Access the admin panel via the gear icon in the top left corner +- Default admin password: `admin` (change this immediately) +- Use `/admin/logout` to log out of the admin panel + +### Security Improvements + +- API keys are no longer stored in config files +- Admin panel requires authentication +- Input validation on all form fields +- Secure session management +- XML output is properly escaped + +### Performance Improvements + +- Background polling eliminates wait times for API calls +- Data is cached in memory for instant access +- Reduced API calls through intelligent caching +- Non-blocking user interface + +### TODO + +- Add more themes +- Enhanced admin features +- Database integration for larger datasets diff --git a/__pycache__/taplist.cpython-312.pyc b/__pycache__/taplist.cpython-312.pyc new file mode 100644 index 0000000..8d1e750 Binary files /dev/null and b/__pycache__/taplist.cpython-312.pyc differ diff --git a/beers.xml b/beers.xml index 749ef1b..c0c2f8e 100644 --- a/beers.xml +++ b/beers.xml @@ -1,2 +1,2 @@ - \ No newline at end of file +Test BeerIPA6.54582024-01-01123 \ No newline at end of file diff --git a/config.json b/config.json index fdaabbe..195923f 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,9 @@ { - "api_key": "Enter API key", "title": "Enter your Title Here", "selected_theme": "theme_one", "selected_theme_style": "light", - "refresh_interval": "3600", + "refresh_interval": 3600, + "admin_password": "admin", "folders": [], "selected_folders": [] } \ No newline at end of file diff --git a/taplist.py b/taplist.py index 0036061..9db0e1a 100644 --- a/taplist.py +++ b/taplist.py @@ -2,7 +2,11 @@ import requests import json import xml.etree.ElementTree as ET -from flask import Flask, render_template, request +import threading +import time +import logging +from datetime import datetime, timedelta +from flask import Flask, render_template, request, session, redirect, url_for, flash class TaplistApp: CONFIG_FILE = "config.json" @@ -10,30 +14,216 @@ class TaplistApp: def __init__(self): self.app = Flask(__name__) + # Set a secret key for sessions + self.app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'your-secret-key-change-this') + + # Setup logging + logging.basicConfig(level=logging.INFO) + self.logger = logging.getLogger(__name__) + self.config = self.load_config() self.title = self.config.get("title", "Enter your Title Here") - self.api_key = self.config.get("api_key", "Enter API key") + + # Get API key from environment variable first, then config file + self.api_key = os.environ.get('BREWERS_FRIEND_API_KEY') or self.config.get("api_key", "Enter API key") + self.selected_theme = self.config.get("selected_theme", "theme_one") self.selected_theme_style = self.config.get("selected_theme_style", "light") - self.refresh_interval = self.config.get("refresh_interval", 3600) + self.refresh_interval = int(self.config.get("refresh_interval", 3600)) + self.admin_password = self.config.get("admin_password", "admin") # Default password self.folders = self.config.get("folders", []) - self.beers = [] + + # Cache for beer data + self.cached_beers = [] + self.last_fetch_time = None + self.cache_lock = threading.Lock() + self.polling_active = False + + # Start background polling thread + self.start_background_polling() # Define routes - self.app.add_url_rule("/", view_func=self.taplist) - self.app.add_url_rule("/admin", view_func=self.admin, methods=["GET", "POST"]) + self.app.add_url_rule("/", "taplist", view_func=self.taplist) + self.app.add_url_rule("/admin", "admin", view_func=self.admin, methods=["GET", "POST"]) + self.app.add_url_rule("/admin/login", "admin_login", view_func=self.admin_login, methods=["GET", "POST"]) + self.app.add_url_rule("/admin/logout", "admin_logout", view_func=self.admin_logout) def load_config(self): if os.path.exists(self.CONFIG_FILE): with open(self.CONFIG_FILE, "r") as file: - return json.load(file) - return {"api_key": "Enter API key", "title": "Enter your Title Here", "selected_theme": "theme_one", "selected_theme_style": "light", "folders": []} + config = json.load(file) + # Remove API key from config file if it exists, we'll use env var + if "api_key" in config and config["api_key"] != "Enter API key": + self.logger.warning("API key found in config file. Consider moving to environment variable BREWERS_FRIEND_API_KEY") + return config + return { + "title": "Enter your Title Here", + "selected_theme": "theme_one", + "selected_theme_style": "light", + "refresh_interval": 3600, + "admin_password": "admin", + "folders": [], + "selected_folders": [] + } def save_config(self): + # Don't save API key to config file if it's set via environment variable + config_to_save = self.config.copy() + if os.environ.get('BREWERS_FRIEND_API_KEY'): + config_to_save.pop("api_key", None) + with open(self.CONFIG_FILE, "w") as file: - json.dump(self.config, file, indent=4) + json.dump(config_to_save, file, indent=4) self.config = self.load_config() + def start_background_polling(self): + """Start the background thread for polling API data.""" + if not self.polling_active: + self.polling_active = True + thread = threading.Thread(target=self._background_poll, daemon=True) + thread.start() + self.logger.info("Background polling thread started") + + def _background_poll(self): + """Background thread function to poll API data at intervals.""" + while self.polling_active: + try: + if self.api_key and self.api_key != "Enter API key": + self._fetch_and_cache_beers() + time.sleep(self.refresh_interval) + except Exception as e: + self.logger.error(f"Error in background polling: {e}") + time.sleep(60) # Wait 1 minute before retrying on error + + def _fetch_and_cache_beers(self): + """Fetch beer data from API and cache it.""" + selected_folders = self.config.get("selected_folders", []) + if not selected_folders: + return + + try: + self.logger.info("Fetching beer data from API...") + beers = self._fetch_beers_from_api() + + with self.cache_lock: + self.cached_beers = beers + self.last_fetch_time = datetime.now() + + # Also save to XML file for persistence + if beers: + self._save_beers_to_xml(beers) + + self.logger.info(f"Successfully cached {len(beers)} beers") + except Exception as e: + self.logger.error(f"Error fetching and caching beers: {e}") + + def _fetch_beers_from_api(self): + """Fetch beers from the API (optimized version).""" + if not self.api_key or self.api_key == "Enter API key": + return [] + + selected_folders = self.config.get("selected_folders", []) + if not selected_folders: + return [] + + try: + url = "https://api.brewersfriend.com/v1/brewsessions" + headers = {"X-API-KEY": self.api_key} + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + brewsessions = response.json().get("brewsessions", []) + + # Filter sessions by selected folders first + filtered_sessions = [ + session for session in brewsessions + if session.get("folder_name") in selected_folders + ] + + beers = [] + # Batch the detailed requests + for session in filtered_sessions: + try: + brew_id = session.get("id") + if not brew_id: + continue + + brew_url = f"https://api.brewersfriend.com/v1/brewsessions/{brew_id}" + brew_response = requests.get(brew_url, headers=headers, timeout=15) + brew_response.raise_for_status() + brew_data = brew_response.json().get("brewsessions", [])[0] + + if brew_data: + recipe = brew_data.get("recipe", {}) + beers.append({ + "title": str(recipe.get("title", "Unknown"))[:100], # Limit length + "stylename": str(recipe.get("stylename", "Unknown"))[:50], + "abv_alt": min(float(brew_data.get("current_stats", {}).get("abv_alt", 0)), 50.0), # Cap at 50% + "ibutinseth": min(int(recipe.get("ibutinseth", 0)), 200), # Cap at 200 IBU + "srmmorey": min(int(recipe.get("srmmorey", 0)), 80), # Cap at 80 SRM + "userdate": str(brew_data.get("userdate", "Unknown"))[:20], + "id": int(recipe.get("id", 0)), + }) + except Exception as e: + self.logger.warning(f"Error fetching brew session {brew_id}: {e}") + continue + + return beers + except Exception as e: + self.logger.error(f"Error fetching beers from API: {e}") + return [] + + def _save_beers_to_xml(self, beers): + """Save beers to XML file.""" + try: + root = ET.Element("beers") + for beer in beers: + beer_elem = ET.SubElement(root, "beer") + for key, value in beer.items(): + elem = ET.SubElement(beer_elem, key) + # Escape XML content + elem.text = str(value).replace("&", "&").replace("<", "<").replace(">", ">") + + tree = ET.ElementTree(root) + tree.write(self.BEER_FILE, encoding="utf-8", xml_declaration=True) + except Exception as e: + self.logger.error(f"Error saving beers to XML: {e}") + + def get_cached_beers(self): + """Get cached beer data.""" + with self.cache_lock: + if self.cached_beers: + return self.cached_beers.copy() + + # Fallback to loading from XML file if cache is empty + return self.load_beers() + + def admin_login(self): + """Handle admin login.""" + if request.method == "POST": + password = request.form.get("password", "").strip() + if password == self.admin_password: + session["admin_authenticated"] = True + flash("Login successful!", "success") + return redirect(url_for("admin")) + else: + flash("Invalid password!", "error") + + return render_template( + f"{self.selected_theme}/admin_login.html", + selected_theme=self.selected_theme, + selected_theme_style=self.selected_theme_style + ) + + def admin_logout(self): + """Handle admin logout.""" + session.pop("admin_authenticated", None) + flash("Logged out successfully!", "info") + return redirect(url_for("taplist")) + + def require_admin_auth(self): + """Check if admin is authenticated.""" + return session.get("admin_authenticated", False) + def load_beers(self): if not os.path.exists(self.BEER_FILE): @@ -73,21 +263,28 @@ def srm_color(self, srm): return '#5A0E16' def taplist(self): - self.fetch_beers_by_folder() - self.beers = self.load_beers() + # Use cached data instead of fetching from API + beers = self.get_cached_beers() message = None if self.api_key == "Enter API key": - message = "No API available. Try adding API and select a folder." - elif not self.beers and self.api_key != "Enter API key": + message = "No API available. Try adding API and select a folder." + elif not beers and self.api_key != "Enter API key": message = "No beers available. Try adding some to your selected folders." + # Add cache status info + cache_status = "" + if self.last_fetch_time: + time_since_update = datetime.now() - self.last_fetch_time + cache_status = f"Last updated: {time_since_update.seconds // 60} minutes ago" + return render_template( f"{self.selected_theme}/taplist.html", - beers=self.beers, + beers=beers, srm_color=self.srm_color, title=self.title, message=message, + cache_status=cache_status, selected_theme=self.selected_theme, selected_theme_style=self.selected_theme_style, refresh_interval=self.refresh_interval @@ -95,71 +292,94 @@ def taplist(self): def admin(self): + # Check authentication + if not self.require_admin_auth(): + return redirect(url_for("admin_login")) + message = None if request.method == "POST": message = "" + + # Validate and sanitize inputs if "title" in request.form: - self.title = request.form.get("title", self.title) - self.config["title"] = self.title + title = request.form.get("title", "").strip()[:100] # Limit length + if title: + self.title = title + self.config["title"] = self.title if "available_folders" in request.form: selected_items = request.form.getlist("available_folders") + # Validate folder names + selected_items = [item.strip()[:50] for item in selected_items if item.strip()] current_selected_folders = self.config.get("selected_folders", []) updated_selected_folders = list(set(current_selected_folders + selected_items)) self.config["selected_folders"] = updated_selected_folders if "selected_folders" in request.form: selected_items = request.form.getlist("selected_folders") + selected_items = [item.strip()[:50] for item in selected_items if item.strip()] current_selected_folders = self.config.get("selected_folders", []) updated_selected_folders = [folder for folder in current_selected_folders if folder not in selected_items] self.config["selected_folders"] = updated_selected_folders if not self.config["selected_folders"]: self.clear_beers_file() - if "api_key" in request.form: - new_api_key = request.form.get("api_key", "").strip() - if new_api_key != self.api_key: - self.api_key = new_api_key - self.config["api_key"] = self.api_key + if "admin_password" in request.form: + new_password = request.form.get("admin_password", "").strip() + if len(new_password) >= 4: # Minimum password length + self.admin_password = new_password + self.config["admin_password"] = self.admin_password + message += "Admin password updated successfully.\n" + else: + message += "Password must be at least 4 characters long.\n" if "refresh_interval" in request.form: - new_refresh_interval = request.form.get("refresh_interval", "").strip() - if new_refresh_interval != self.refresh_interval: - self.refresh_interval = new_refresh_interval - self.config["refresh_interval"] = self.refresh_interval + try: + new_refresh_interval = int(request.form.get("refresh_interval", "3600")) + if 60 <= new_refresh_interval <= 86400: # Between 1 minute and 1 day + self.refresh_interval = new_refresh_interval + self.config["refresh_interval"] = self.refresh_interval + message += "Refresh interval updated successfully.\n" + else: + message += "Refresh interval must be between 60 and 86400 seconds.\n" + except ValueError: + message += "Invalid refresh interval value.\n" if "theme" in request.form: selected_theme = request.form.get("theme", "").strip() - self.config["selected_theme"] = selected_theme - self.selected_theme = selected_theme + if selected_theme in ["theme_one", "theme_two"]: # Validate theme + self.config["selected_theme"] = selected_theme + self.selected_theme = selected_theme if "theme_style" in request.form: selected_theme_style = request.form.get("theme_style", "").strip() - self.config["selected_theme_style"] = selected_theme_style - self.selected_theme_style = selected_theme_style + if selected_theme_style in ["light", "dark"]: # Validate theme style + self.config["selected_theme_style"] = selected_theme_style + self.selected_theme_style = selected_theme_style + # Force refresh of folders and beer data all_folders = self.fetch_folders_from_api() if all_folders: self.folders = all_folders self.config["folders"] = self.folders - message += "API key updated successfully, and folders refreshed.\n" - else: - message += "Invalid API key. Please check and try again.\n" - + # Trigger immediate background refresh + threading.Thread(target=self._fetch_and_cache_beers, daemon=True).start() + message += "Folders refreshed successfully.\n" + elif self.api_key and self.api_key != "Enter API key": + message += "Error refreshing folders. Please check API key.\n" self.save_config() message += "Changes saved successfully." all_folders = self.fetch_folders_from_api() - selected_folders = self.config.get("selected_folders", []) unselected_folders = [folder for folder in all_folders if folder not in selected_folders] return render_template( f"{self.selected_theme}/admin.html", title=self.title, - api_key=self.api_key, + api_key_status="Set via environment variable" if os.environ.get('BREWERS_FRIEND_API_KEY') else "Not set", selected_theme=self.selected_theme, selected_theme_style=self.selected_theme_style, selected_folders=selected_folders, @@ -169,80 +389,37 @@ def admin(self): ) def fetch_folders_from_api(self): - if not self.api_key: + if not self.api_key or self.api_key == "Enter API key": return [] try: url = "https://api.brewersfriend.com/v1/brewsessions" headers = {"X-API-KEY": self.api_key} - response = requests.get(url, headers=headers) + response = requests.get(url, headers=headers, timeout=30) response.raise_for_status() brewsessions = response.json().get("brewsessions", []) - return list({session.get("folder_name") for session in brewsessions if session.get("folder_name")}) + folders = list({session.get("folder_name") for session in brewsessions if session.get("folder_name")}) + return sorted(folders) # Sort for consistency except Exception as e: - print(f"Error fetching folders: {e}") - return [] - - def fetch_beers_by_folder(self): - if not self.api_key: - return [] - - selected_folders = self.config.get("selected_folders", []) - if not selected_folders: - return [] - - try: - url = "https://api.brewersfriend.com/v1/brewsessions" - headers = {"X-API-KEY": self.api_key} - response = requests.get(url, headers=headers) - response.raise_for_status() - brewsessions = response.json().get("brewsessions", []) - - beers = [] - for session in brewsessions: - if session.get("folder_name") in selected_folders: - brew_id = session.get("id") - brew_url = f"https://api.brewersfriend.com/v1/brewsessions/{brew_id}" - brew_response = requests.get(brew_url, headers=headers) - brew_response.raise_for_status() - brew_data = brew_response.json().get("brewsessions", [])[0] - - if brew_data: - recipe = brew_data.get("recipe", {}) - beers.append({ - "title": recipe.get("title", "Unknown"), - "stylename": recipe.get("stylename", "Unknown"), - "abv_alt": float(brew_data.get("current_stats", {}).get("abv_alt", 0)), - "ibutinseth": int(recipe.get("ibutinseth", 0)), - "srmmorey": int(recipe.get("srmmorey", 0)), - "userdate": brew_data.get("userdate", "Unknown"), - "id": int(recipe.get("id", 0)), - }) - - if beers: - root = ET.Element("beers") - for beer in beers: - beer_elem = ET.SubElement(root, "beer") - for key, value in beer.items(): - elem = ET.SubElement(beer_elem, key) - elem.text = str(value) - - tree = ET.ElementTree(root) - tree.write(self.BEER_FILE, encoding="utf-8", xml_declaration=True) - - return beers - except Exception as e: - print(f"Error fetching beers: {e}") + self.logger.error(f"Error fetching folders: {e}") return [] def clear_beers_file(self): - if os.path.exists(self.BEER_FILE): + """Clear the beers XML file and cache.""" + try: with open(self.BEER_FILE, "w") as file: file.write("") + + with self.cache_lock: + self.cached_beers = [] + self.last_fetch_time = None + + except Exception as e: + self.logger.error(f"Error clearing beers file: {e}") def run(self): """Run the Flask app.""" - self.app.run(host="0.0.0.0", port=5000, debug=True) + self.app.run(host="0.0.0.0", port=5000, debug=False) # Disable debug in production if __name__ == "__main__": app = TaplistApp() diff --git a/templates/theme_one/admin.html b/templates/theme_one/admin.html index 15fd209..ba21087 100644 --- a/templates/theme_one/admin.html +++ b/templates/theme_one/admin.html @@ -11,6 +11,7 @@

Admin Panel

Back to Taplist + Logout

Edit Taplist Title

@@ -18,12 +19,16 @@

Edit Taplist Title

Brewers Friend API Key

- - +

API Key Status: {{ api_key_status }}

+

Set BREWERS_FRIEND_API_KEY environment variable to configure API access

-

Refresh Page (in sec default:1hour 3600)

+

Admin Password

+ + + +

Refresh Page (in sec, default: 1 hour = 3600)

- +

Select Theme

diff --git a/templates/theme_one/admin_login.html b/templates/theme_one/admin_login.html new file mode 100644 index 0000000..d0c4316 --- /dev/null +++ b/templates/theme_one/admin_login.html @@ -0,0 +1,33 @@ + + + + + + Admin Login + + + +
+

Admin Login

+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + +
+ + +
+ + +
+ + \ No newline at end of file diff --git a/templates/theme_two/admin.html b/templates/theme_two/admin.html index b7232a0..7ab5c77 100644 --- a/templates/theme_two/admin.html +++ b/templates/theme_two/admin.html @@ -11,6 +11,7 @@

Admin Panel

Back to Taplist + Logout
@@ -20,9 +21,17 @@

Site Settings

+

Brewers Friend API Key

+

API Key Status: {{ api_key_status }}

+

Set BREWERS_FRIEND_API_KEY environment variable to configure API access

+ +

Admin Password

+ + +

Refresh Page (in sec, default: 1 hour = 3600)

- +

Select Theme

diff --git a/templates/theme_two/admin_login.html b/templates/theme_two/admin_login.html new file mode 100644 index 0000000..d0c4316 --- /dev/null +++ b/templates/theme_two/admin_login.html @@ -0,0 +1,33 @@ + + + + + + Admin Login + + + +
+

Admin Login

+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + +
+ + +
+ + +
+ + \ No newline at end of file