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