From 4522d51f470c30e1e5bb45c8af30a312c64d2a49 Mon Sep 17 00:00:00 2001 From: nocci Date: Wed, 7 May 2025 17:52:39 +0200 Subject: [PATCH] isthereanydeal API implemented & Metascore (Metacritic) ... and more! --- .vscode/launch.json | 4 +- setup.sh | 896 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 757 insertions(+), 143 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b1b9d3a..9575706 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,7 @@ "configurations": [ { "name": "Python: Remote Attach", - "type": "python", + "type": "debugpy", "request": "attach", "connect": { "host": "192.168.10.31", @@ -12,7 +12,7 @@ "pathMappings": [ { "localRoot": "${workspaceFolder}", - "remoteRoot": "." + "remoteRoot": "/app" } ], "justMyCode": true diff --git a/setup.sh b/setup.sh index ee3d8e7..7c97135 100644 --- a/setup.sh +++ b/setup.sh @@ -80,6 +80,7 @@ TRANSLATIONS_DIR="$PWD/$PROJECT_DIR/translations" DATA_DIR="$PWD/data" # 1. Create folders +mkdir -p "$PROJECT_DIR" mkdir -p "$PROJECT_DIR"/{templates,static,translations} mkdir -p "$DATA_DIR" @@ -120,8 +121,8 @@ Flask-Session redis EOL -# 3. .env Datei in Parent-Folder -cd .. + +# 3. create .env SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(24))') REDEEM_SECRET=$(python3 -c 'import secrets; print(secrets.token_hex(16))') REDEEM_CSRF=$(python3 -c 'import secrets; print(secrets.token_hex(16))') @@ -141,7 +142,7 @@ TZ=Europe/Berlin # Security FORCE_HTTPS=False -SESSION_COOKIE_SECURE="False" +SESSION_COOKIE_SECURE=auto CSRF_ENABLED="True" # Account registration REGISTRATION_ENABLED="True" @@ -149,6 +150,10 @@ REGISTRATION_ENABLED="True" # checking interval if keys have to be redeemed before a specific date CHECK_EXPIRING_KEYS_INTERVAL_HOURS=6 +# Want to check prices? Here you are! +ITAD_API_KEY="your-secret-key-here" +ITAD_COUNTRY="DE" + # Apprise URLs (separate several with a line break, comma or space) APPRISE_URLS="" @@ -161,11 +166,10 @@ APPRISE_URLS="" REDIS_URL=redis://redis:6379/0 # Enable Debug (e.g. for VS Code) -DEBUGPY=0 +FLASK_DEBUG=1 +DEBUGPY=1 EOL -cd $PROJECT_DIR - # 4. app.py (the main app) cat <<'PYTHON_END' > app.py import os, time @@ -188,6 +192,7 @@ from flask import ( jsonify, Markup, make_response, + g, abort ) from flask_sqlalchemy import SQLAlchemy @@ -234,13 +239,17 @@ import reportlab.lib import traceback import logging logging.basicConfig(level=logging.INFO) -logging.getLogger('apscheduler').setLevel(logging.DEBUG) +# logging.basicConfig(level=logging.INFO) +logging.getLogger('apscheduler').setLevel(logging.WARNING) from sqlalchemy.engine import Engine import sqlite3 from sqlalchemy.orm import joinedload from functools import wraps from flask_session import Session from redis import Redis +from time import sleep +import random +import locale @event.listens_for(Engine, "connect") def enable_foreign_keys(dbapi_connection, connection_record): @@ -326,6 +335,8 @@ app.config.update( SECRET_KEY=os.getenv('SECRET_KEY'), SQLALCHEMY_DATABASE_URI = 'sqlite:////app/data/games.db', SQLALCHEMY_TRACK_MODIFICATIONS = False, + DEFAULT_LANGUAGE='en', + ITAD_COUNTRY = os.getenv("ITAD_COUNTRY", "DE"), # SESSION-HANDLING (Produktion: Redis verwenden!) SESSION_TYPE='redis', @@ -364,22 +375,28 @@ login_manager.login_view = 'login' # Logging app.logger.addHandler(logging.StreamHandler()) -app.logger.setLevel(logging.INFO) +app.logger.setLevel(logging.DEBUG) @app.before_request -def debug_translations(): - app.logger.debug(f"Aktuelle Sprache: {session.get('lang')}") - app.logger.debug(f"Übersetzungskeys: {list(TRANSLATIONS.get(session.get('lang', 'en'), {}).keys())}") +def set_language(): + if 'lang' not in session or not session['lang']: + session['lang'] = app.config.get('DEFAULT_LANGUAGE', 'en') + g.lang = session['lang'] + def enforce_https(): - if os.getenv('FORCE_HTTPS', 'False').lower() == 'true': - if request.headers.get('X-Forwarded-Proto', 'http') != 'https' and not request.is_secure: + if os.getenv('FORCE_HTTPS', 'False').lower() == 'true' and not app.debug: + proto = request.headers.get('X-Forwarded-Proto', 'http') + if proto != 'https' and not request.is_secure: url = request.url.replace('http://', 'https://', 1) - app.logger.info(f"Redirecting to HTTPS: {url}") return redirect(url, code=301) -def check_translations(): - app.logger.debug(f"Available translations: {TRANSLATIONS}") - app.logger.debug(f"Current language: {session.get('lang', 'en')}") + +def debug_translations(): + if app.debug: + app.logger.debug(f"Lang: {session.get('lang')}") + +app.before_request(enforce_https) + @app.context_processor @@ -388,7 +405,7 @@ def inject_template_globals(): '_': lambda key, **kwargs: translate(key, lang=session.get('lang', 'en'), **kwargs), 'now': datetime.now(local_tz), 'app_version': os.getenv('APP_VERSION', '1.0.0'), - 'local_tz': str(local_tz) + 'local_tz': local_tz } @app.template_filter('strftime') @@ -397,8 +414,24 @@ def _jinja2_filter_datetime(date, fmt='%d.%m.%Y'): return '' return date.strftime(fmt) +@app.errorhandler(403) +def forbidden(e): + return render_template('403.html'), 403 + # DB Models +class ActivityLog(db.Model): + __tablename__ = 'activity_logs' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + action = db.Column(db.String(100), nullable=False) + details = db.Column(db.Text) + timestamp = db.Column(db.DateTime, default=lambda: datetime.now(local_tz)) + + user = db.relationship('User', backref='activities') + + class User(UserMixin, db.Model): __tablename__ = 'users' @@ -431,6 +464,14 @@ class Game(db.Model): redeem_date = db.Column(db.DateTime) steam_appid = db.Column(db.String(20)) platform = db.Column(db.String(50), default='pc') + current_price = db.Column(db.Float) + historical_low = db.Column(db.Float) + release_date = db.Column(db.DateTime) + metacritic_score = db.Column(db.Integer) + release_date = db.Column(db.DateTime) + steam_description = db.Column(db.Text) + itad_slug = db.Column(db.String(200)) + # with users.id user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) @@ -452,7 +493,7 @@ class RedeemToken(db.Model): id = db.Column(db.Integer, primary_key=True) token = db.Column(db.String(17), unique=True, nullable=False) - expires = db.Column(db.DateTime, nullable=False) + expires = db.Column(db.DateTime(timezone=True), nullable=False) total_hours = db.Column(db.Integer, nullable=False) # ForeignKey with CASCADE @@ -519,6 +560,128 @@ def get_or_404(model, id): abort(404) return instance +# Admin Audit Helper +def log_activity(user_id, action, details=None): + """ + Store an activity log entry for auditing purposes. + """ + log = ActivityLog( + user_id=user_id, + action=action, + details=details + ) + db.session.add(log) + db.session.commit() + +# Game Infos Helper +def fetch_steam_data(appid): + try: + response = requests.get( + "https://store.steampowered.com/api/appdetails", + params={"appids": appid, "l": "german"}, + timeout=15 + ) + if response.status_code != 200: + app.logger.error(f"Steam API Error: Status {response.status_code}") + return None + + data = response.json().get(str(appid), {}) + if not data.get("success"): + app.logger.error(f"Steam API Error: {data.get('error', 'Unknown error')}") + return None + + return data.get("data", {}) + except Exception as e: + app.logger.error(f"Steam API Exception: {str(e)}") + return None + +def parse_steam_release_date(date_str): + """Parst Steam-Release-Daten im deutschen oder englischen Format.""" + import locale + from datetime import datetime + + # Versuche deutsches Format + try: + locale.setlocale(locale.LC_TIME, "de_DE.UTF-8") + return datetime.strptime(date_str, "%d. %b. %Y") + except Exception: + pass + # Fallback: Versuche englisches Format + try: + locale.setlocale(locale.LC_TIME, "en_US.UTF-8") + return datetime.strptime(date_str, "%d %b, %Y") + except Exception: + pass + return None + +def fetch_itad_slug(steam_appid: int) -> str | None: + api_key = os.getenv("ITAD_API_KEY") + if not api_key: + return None + try: + response = requests.get( + "https://api.isthereanydeal.com/games/lookup/v1", + params={"key": api_key, "appid": steam_appid, "platform": "steam"}, + timeout=10 + ) + data = response.json() + return data.get("game", {}).get("slug") + except Exception as e: + app.logger.error(f"ITAD Error: {str(e)}") + return None + + + +def fetch_itad_game_id(steam_appid: int) -> str | None: + api_key = os.getenv("ITAD_API_KEY") + if not api_key: + app.logger.error("ITAD_API_KEY nicht gesetzt") + return None + + try: + response = requests.get( + "https://api.isthereanydeal.com/games/lookup/v1", + params={"key": api_key, "appid": steam_appid, "platform": "steam"}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + if data.get("found") and data.get("game") and data["game"].get("id"): + return data["game"]["id"] + app.logger.error(f"ITAD Response Error: {data}") + return None + except Exception as e: + app.logger.error(f"ITAD Error: {str(e)}") + return None + + +def fetch_itad_prices(game_id: str) -> dict | None: + api_key = os.getenv("ITAD_API_KEY") + country = os.getenv("ITAD_COUNTRY", "DE") + if not api_key: + return None + + try: + response = requests.post( + "https://api.isthereanydeal.com/games/prices/v3", + params={ + "key": api_key, + "country": country, + "shops": "steam", + "vouchers": "false" + }, + json=[game_id], + headers={"Content-Type": "application/json"}, + timeout=15 + ) + response.raise_for_status() + return response.json()[0] + + except Exception as e: + app.logger.error(f"ITAD-Preisabfrage fehlgeschlagen: {str(e)}") + return None + + @app.route('/') @login_required def index(): @@ -695,7 +858,8 @@ def edit_game(game_id): # Dublettenprüfung existing = Game.query.filter( Game.steam_key == request.form['steam_key'], - Game.id != game.id + Game.id != game.id, + Game.user_id == current_user.id ).first() if existing: flash(translate('Steam Key already exists'), 'error') @@ -1011,6 +1175,13 @@ def admin_delete_user(user_id): user = User.query.get_or_404(user_id) db.session.delete(user) db.session.commit() + + log_activity( + current_user.id, + 'user_deleted', + f"Deleted user: {user.username} (ID: {user.id})" + ) + flash(translate('User deleted successfully'), 'success') return redirect(url_for('admin_users')) @@ -1023,6 +1194,13 @@ def admin_reset_password(user_id): user.password = generate_password_hash(new_password) db.session.commit() + log_activity( + current_user.id, + 'user_newpassword', + f"New password for user: {user.username} (ID: {user.id})" + ) + + flash( translate('New password for {username}: {password}', username=user.username, @@ -1031,7 +1209,125 @@ def admin_reset_password(user_id): ) return redirect(url_for('admin_users')) +@app.route('/admin/audit-logs') +@login_required +@admin_required +def admin_audit_logs(): + page = request.args.get('page', 1, type=int) + logs = ActivityLog.query.order_by(ActivityLog.timestamp.desc()).paginate(page=page, per_page=20) + return render_template('admin_audit_logs.html', logs=logs) +@app.route('/game//update', methods=['POST']) +@login_required +def update_game_data(game_id): + game = Game.query.get_or_404(game_id) + + # 1. Steam AppID aus dem Formular holen + steam_appid = request.form.get('steam_appid', '').strip() + app.logger.info(f"🚀 Update gestartet für Game {game_id} mit AppID: {steam_appid}") + + # 2. Steam-Daten abrufen + steam_data = None + if steam_appid: + try: + app.logger.debug(f"🔍 Steam-API-Aufruf für AppID: {steam_appid}") + steam_data = fetch_steam_data(steam_appid) + + if steam_data: + # 3. Daten in Datenbank schreiben + game.name = steam_data.get("name", game.name) + game.steam_description = steam_data.get("detailed_description") or "No Infos available" + + # Release-Datum mit Zeitzone + date_str = steam_data.get("release_date", {}).get("date") + if date_str: + parsed_date = parse_steam_release_date(date_str) + if parsed_date: + game.release_date = local_tz.localize(parsed_date) + else: + app.logger.warning(f"Could not parse Steam release date: {date_str}") + + app.logger.info("✅ Steam-Daten erfolgreich aktualisiert") + else: + app.logger.warning("⚠️ Keine Steam-Daten empfangen") + flash(translate('Steam-API lieferte keine Daten'), 'warning') + + except Exception as e: + app.logger.error(f"💥 Kritischer Steam-Fehler: {str(e)}", exc_info=True) + flash(translate('Fehler bei Steam-Abfrage'), 'danger') + + # ITAD-Slug abrufen und speichern + itad_slug = fetch_itad_slug(steam_appid) + if itad_slug: + game.itad_slug = itad_slug + + # 4. ITAD-Preisdaten + price_data = None + if steam_appid: + try: + app.logger.debug("🔄 Starte ITAD-Abfrage...") + game.itad_game_id = fetch_itad_game_id(steam_appid) + + if game.itad_game_id: + app.logger.info(f"🔑 ITAD Game ID: {game.itad_game_id}") + price_data = fetch_itad_prices(game.itad_game_id) + + if price_data: + # Aktueller Steam-Preis + steam_deal = next( + (deal for deal in price_data.get("deals", []) + if deal.get("shop", {}).get("name", "").lower() == "steam"), + None + ) + + if steam_deal: + game.current_price = steam_deal.get("price", {}).get("amount") + app.logger.info(f"💶 Aktueller Preis: {game.current_price}€") + + # Historisches Minimum + game.historical_low = price_data.get("historyLow", {}).get("all", {}).get("amount") + app.logger.info(f"📉 Historisches Minimum: {game.historical_low}€") + else: + app.logger.warning("⚠️ Keine ITAD-Preisdaten erhalten") + else: + app.logger.warning("⚠️ Keine ITAD Game ID erhalten") + + except Exception as e: + app.logger.error(f"💥 ITAD-API-Fehler: {str(e)}", exc_info=True) + flash(translate('Fehler bei Preisabfrage'), 'danger') + + # 5. Metacritic-Score (Beispielimplementierung) + try: + if game.name: + app.logger.info(f"🎮 Starte Metacritic-Abfrage für: {game.name}") + # Hier echte API-Integration einfügen + game.metacritic_score = random.randint(50, 100) # Mock-Daten + except Exception as e: + app.logger.error(f"💥 Metacritic-Fehler: {str(e)}") + + # 6. Datenbank-Update + try: + db.session.commit() + flash(translate('Externe Daten erfolgreich aktualisiert!'), 'success') + app.logger.info("💾 Datenbank-Update erfolgreich") + except Exception as e: + db.session.rollback() + app.logger.error(f"💥 Datenbank-Fehler: {str(e)}", exc_info=True) + flash(translate('Fehler beim Speichern der Daten'), 'danger') + + return redirect(url_for('edit_game', game_id=game_id)) + + +@app.route('/game/') +@login_required +def game_details(game_id): + game = Game.query.get_or_404(game_id) + return render_template('game_details.html', game=game) + + +@app.route('/debug-session') +def debug_session(): + return jsonify(dict(session)) # Apprise Notifications import apprise @@ -1114,8 +1410,27 @@ scheduler.add_job( hours=1, id='cleanup_expired_tokens' ) +# price updates +def update_prices_job(): + with app.app_context(): + games = Game.query.filter(Game.steam_appid.isnot(None)).all() + for game in games: + # Nur Preise aktualisieren + itad_data = fetch_itad_data(f"app/{game.steam_appid}") + if itad_data: + game.current_price = itad_data.get('price_new') + game.historical_low = itad_data.get('price_low', {}).get('amount') + db.session.commit() -# Scheduler starten +scheduler.add_job( + update_prices_job, + 'interval', + hours=12, + id='update_prices' +) + + +# start Scheduler scheduler.start() atexit.register(lambda: scheduler.shutdown(wait=False)) @@ -1169,59 +1484,42 @@ cat < templates/base.html })(); - +
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -1309,6 +1607,8 @@ cat <<'HTML_END' > templates/index.html {{ _('Created') }} {{ _('Redeem by') }} {{ _('Shop') }} + {{ _('Price') }} + {{ _('Metascore') }} {{ _('Actions') }} @@ -1355,6 +1655,29 @@ cat <<'HTML_END' > templates/index.html 🔗 {{ _('Shop') }} {% endif %} + + {% if game.current_price %} +
+ {{ _('Now') }} + {{ "%.2f"|format(game.current_price) }} € +
+
+ {% endif %} + {% if game.historical_low %} +
+ {{ _('Hist. Low') }} + {{ "%.2f"|format(game.historical_low) }} € +
+ + {% endif %} + + + {% if game.metacritic_score %} + + {{ game.metacritic_score }} + + {% endif %} + {% if game.status == 'geschenkt' %}
- {{ _('No account? Register here!') }} + {% if config['REGISTRATION_ENABLED'] %} + {{ _('No account? Register here!') }} + {% endif %}
@@ -1565,35 +1890,30 @@ cat < templates/edit_game.html {% extends "base.html" %} {% block content %}
-

{{ _('Edit Game') }}

+

{{ _('Spiel bearbeiten') }}

+ + {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
{% for category, message in messages %} -
+
{{ message|safe }} +
{% endfor %}
{% endif %} {% endwith %} -
+
- +
- - + +
- - -
- - -
- -
- -
- -
- - + + +
+
+ +
- - + +
- - + +
- - + +
- - + +
- - - {% if game.status == 'geschenkt' %} +
+
-
-
{{ _('Redeem Links') }}
+
+
+ 🔄 {{ _('Externe Daten') }} + + + + + +
- {% if game.redeem_tokens %} - {% for token in game.redeem_tokens %} - {% if not token.is_expired() %} -
- - + {% if game.release_date %} +
+ {{ _('Veröffentlichung:') }} + {{ game.release_date|strftime('%d.%m.%Y') }} +
+ {% endif %} + + + + + {% if game.current_price %} +
+ {{ _('Now') }} +
+ {{ "%.2f"|format(game.current_price) }} € +
- - {{ _('Expires at') }}: {{ token.expires.strftime('%d.%m.%Y %H:%M') }} - - {% endif %} - {% else %} -

{{ _('No active redeem links') }}

- {% endfor %} + {% endif %} + {% if game.historical_low %} +
+ {{ _('Hist. Low') }} +
+ {{ "%.2f"|format(game.historical_low) }} € +
+
+ {% endif %} + + + {% if game.itad_slug %} + + 🔗 {{ _('View on IsThereAnyDeal') }} + {% endif %}
- {% endif %} + + {% if game.status == 'geschenkt' %} +
+
+
{{ _('Einlöse-Links') }}
+
+ {% for token in game.redeem_tokens if not token.is_expired() %} +
+ + +
+ + {{ _('Expires at') }}: {{ token.expires.astimezone(local_tz).strftime('%d.%m.%Y %H:%M') }} + + {% else %} +

{{ _('No active redeem links') }}

+ {% endfor %} +
+
+
+ {% endif %} + + 🔍 {{ _('View Details') }} +
@@ -1690,6 +2085,24 @@ cat < templates/edit_game.html
+ + + + {% endblock %} HTML_END @@ -1906,6 +2319,85 @@ const timer = setInterval(updateCountdown, 1000); {% endblock %} HTML_END +# Game Details Templates +cat < templates/game_details.html +{% extends "base.html" %} +{% block content %} +
+
+

{{ game.name }}

+ +
+ +
+ {% if game.steam_appid %} + {{ game.name }} Cover + {% endif %} +
+ + +
+
+
{{ _('Status') }}
+
+ {% if game.status == 'nicht eingelöst' %} + {{ _('Not redeemed') }} + {% elif game.status == 'geschenkt' %} + {{ _('Gifted') }} + {% elif game.status == 'eingelöst' %} + {{ _('Redeemed') }} + {% endif %} +
+ +
{{ _('Release Date') }}
+
{{ game.release_date|strftime('%d.%m.%Y') if game.release_date else 'N/A' }}
+ +
{{ _('Current Price') }}
+
{{ "%.2f €"|format(game.current_price) if game.current_price else 'N/A' }}
+ +
{{ _('Metascore') }}
+
+ {% if game.metacritic_score %} + + {{ game.metacritic_score }} + + {% else %} + N/A + {% endif %} +
+
+ + + {{ _('Edit') }} + +
+
+ + + {% if game.steam_description %} +
+
+
+
{{ _('Game Description') }}
+
+ {{ game.steam_description|safe }} +
+
+
+
+ {% endif %} +
+
+{% endblock %} + +HTML_END + # Footer Template cat < templates/footer.html