From c485a87fb61fe96a46230e7dda32eadd5c05083e Mon Sep 17 00:00:00 2001 From: DragonSenseiGuy <200907890+DragonSenseiGuy@users.noreply.github.com> Date: Sat, 22 Nov 2025 23:00:07 -0500 Subject: [PATCH] site v2 --- src/extensions.py | 7 + src/github/app.py | 235 +++---------------------------- src/github/github_app.py | 7 +- src/github/preferences.py | 13 +- src/github/review.py | 15 +- src/github/webhook.py | 231 ++++++++++++++++++++++++++++++ src/models.py | 13 ++ src/site/static/style.css | 98 +++++++++++++ src/site/templates/base.html | 55 ++++++++ src/site/templates/home.html | 20 +++ src/site/templates/login.html | 21 +++ src/site/templates/settings.html | 39 +++++ src/site/templates/signup.html | 21 +++ src/site/website.py | 112 +++++++++++++++ 14 files changed, 670 insertions(+), 217 deletions(-) create mode 100644 src/extensions.py create mode 100644 src/github/webhook.py create mode 100644 src/models.py create mode 100644 src/site/static/style.css create mode 100644 src/site/templates/base.html create mode 100644 src/site/templates/home.html create mode 100644 src/site/templates/login.html create mode 100644 src/site/templates/settings.html create mode 100644 src/site/templates/signup.html create mode 100644 src/site/website.py diff --git a/src/extensions.py b/src/extensions.py new file mode 100644 index 0000000..0bcc677 --- /dev/null +++ b/src/extensions.py @@ -0,0 +1,7 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_migrate import Migrate + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() diff --git a/src/github/app.py b/src/github/app.py index e768cce..58fa95f 100644 --- a/src/github/app.py +++ b/src/github/app.py @@ -1,226 +1,37 @@ +from flask import Flask +from dotenv import load_dotenv import logging import os -import re -from dotenv import load_dotenv -from flask import Flask, request -import requests - -from github_app import get_installation_token -from preferences import extract_and_save_preference -from review import review_pr, review_comment - -load_dotenv() -app = Flask(__name__) - -logging.basicConfig(level=logging.INFO) - -WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET") -BOT_NAME = os.getenv("BOT_NAME") - - -@app.post("/webhook") -def webhook(): - event = request.headers.get("X-GitHub-Event") - body = request.json - - # Ignore comments made by the bot itself to prevent loops - sender = body.get("sender", {}).get("login") - if sender and sender.lower() == BOT_NAME.lower(): - logging.info(f"Ignoring event triggered by the bot itself ({sender}).") - return "Ignoring own event" - - if event == "issue_comment" and body["action"] == "created": - comment_body = body["comment"]["body"].strip() - pr_number = body["issue"]["number"] - owner = body["repository"]["owner"]["login"] - repo = body["repository"]["name"] - installation_id = body["installation"]["id"] - token = get_installation_token(installation_id) - - if comment_body == "/review": - handle_review_command(owner, repo, pr_number, token) - elif f"@{BOT_NAME.lower()}" in comment_body.lower(): - handle_issue_comment(owner, repo, pr_number, comment_body, token) - - elif event == "pull_request_review_comment" and body["action"] == "created": - if f"@{BOT_NAME.lower()}" in body["comment"]["body"].lower(): - installation_id = body["installation"]["id"] - token = get_installation_token(installation_id) - owner = body["repository"]["owner"]["login"] - repo = body["repository"]["name"] - pr_number = body["pull_request"]["number"] - comment_body = body["comment"]["body"] - comment_id = body["comment"]["id"] - diff_hunk = body["comment"]["diff_hunk"] - handle_review_comment( - owner, repo, pr_number, comment_body, comment_id, token, diff_hunk - ) - - return "Request received" - - -def handle_review_command(owner, repo, pr_number, token): - """ - Handles a command to trigger a full PR review. - """ - headers = {"Authorization": f"token {token}"} - - logging.info(f"Handling /review command for {owner}/{repo} PR #{pr_number}") - - try: - # Get PR data to find the head SHA, title, and body - pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" - pr_response = requests.get(pr_url, headers=headers) - pr_response.raise_for_status() # Check for errors - pr_data = pr_response.json() - - pr_body = pr_data.get("body", "") - pr_title = pr_data.get("title", "") - commit_id = pr_data.get("head", {}).get("sha") - - if not commit_id: - logging.error(f"Could not find head SHA for PR #{pr_number}") - return - - # This is the existing function that does the review - send_review(owner, repo, pr_number, pr_body, pr_title, token, commit_id) - logging.info(f"Successfully triggered review for PR #{pr_number}") - - except requests.exceptions.RequestException as e: - logging.error(f"Failed to fetch PR data for review. Error: {e}") - logging.error(f"Response body: {e.response.text if e.response else 'No response'}") - - -def handle_issue_comment(owner, repo, pr_number, comment_body, token): - if f"@{BOT_NAME.lower()}" not in comment_body.lower(): - logging.warning("handle_issue_comment called without a bot mention. Skipping.") - return - - headers = {"Authorization": f"token {token}"} - - logging.info(f"Handling issue comment for {owner}/{repo} PR #{pr_number}") - - preference_response = extract_and_save_preference(comment_body) - review_response = review_comment(comment_body) - - if preference_response: - response_body = f"{preference_response}\n\n---\n\n{review_response}" - else: - response_body = review_response - - comment_url = ( - f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" - ) - - try: - api_response = requests.post(comment_url, headers=headers, json={"body": response_body}) - api_response.raise_for_status() # Raise an exception for bad status codes - logging.info(f"Successfully posted comment to PR #{pr_number}. Status: {api_response.status_code}") - except requests.exceptions.RequestException as e: - logging.error(f"Failed to post comment to PR #{pr_number}. Error: {e}") - logging.error(f"Response body: {e.response.text if e.response else 'No response'}") - - -def handle_review_comment( - owner, repo, pr_number, comment_body, comment_id, token, diff_hunk -): - if f"@{BOT_NAME.lower()}" not in comment_body.lower(): - logging.warning("handle_review_comment called without a bot mention. Skipping.") - return - - headers = {"Authorization": f"token {token}"} - - logging.info(f"Handling review comment for {owner}/{repo} PR #{pr_number}") - - preference_response = extract_and_save_preference(comment_body) - review_response = review_comment(comment_body, diff_hunk) - - if preference_response: - response_body = f"{preference_response}\n\n---\n\n{review_response}" - else: - response_body = review_response - - comment_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments/{comment_id}/replies" - - try: - api_response = requests.post(comment_url, headers=headers, json={"body": response_body}) - api_response.raise_for_status() - logging.info(f"Successfully posted reply to comment {comment_id} in PR #{pr_number}. Status: {api_response.status_code}") - except requests.exceptions.RequestException as e: - logging.error(f"Failed to post reply to comment {comment_id} in PR #{pr_number}. Error: {e}") - logging.error(f"Response body: {e.response.text if e.response else 'No response'}") - - -def send_review(owner, repo, pr_number, pr_body, pr_title, token, commit_id): - headers = {"Authorization": f"token {token}"} - - # Fetch files from the pull request - files_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files" - files = requests.get(files_url, headers=headers).json() - - try: - with open("../../data/preferences.md", "r") as f: - preferences = f.read() - except FileNotFoundError: - preferences = "" - - # Generate summary of changes - summary = summarize_changes(files, pr_body, pr_title, preferences) - # Post review comments - post_review_comments(owner, repo, pr_number, commit_id, summary, headers) +from ..extensions import db, login_manager, migrate +from ..models import User +def create_app(): + load_dotenv() + app = Flask(__name__) -def post_review_comments(owner, repo, pr_number, commit_id, summary, headers): - comments = parse_review_comments(summary) - for comment in comments: - comment_url = ( - f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments" - ) - payload = { - "body": comment["comment"], - "commit_id": commit_id, - "path": comment["file"], - "line": comment["line"], - } - requests.post(comment_url, headers=headers, json=payload) + app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'a-very-secret-key') + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///../instance/site.db') + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + logging.basicConfig(level=logging.INFO) -def parse_review_comments(summary): - """ - Parses the review summary to extract individual comments. - """ - # The regex looks for a pattern like: `* **app.py:42** - The comment text.` - comment_pattern = re.compile(r"\*\s*\*\*(.+?):(\d+)\*\* - (.+)") - comments = [] - for line in summary.splitlines(): - match = comment_pattern.match(line) - if match: - comments.append( - { - "file": match.group(1), - "line": int(match.group(2)), - "comment": match.group(3), - } - ) - return comments + # Initialize extensions + db.init_app(app) + login_manager.init_app(app) + migrate.init_app(app, db, render_as_batch=True) + login_manager.login_view = 'site.login' -def summarize_changes(files, pr_body, pr_title, preferences): - """ - Generates a summary of the changes in a pull request. - """ - keys_to_remove = ["sha", "blob_url", "raw_url", "contents_url"] - for file in files: - for key in keys_to_remove: - file.pop(key, None) + # Blueprints + from .webhook import webhook as webhook_blueprint + app.register_blueprint(webhook_blueprint) - review_prompt = "" - for file in files: - review_prompt += f"\nFile Name: \n{file['filename']} \nStatus: {file['status']}\nAdditions: {file['additions']}\nDeletions: {file['deletions']}\nChanges: {file['changes']}\nDiff: {file['patch']}" - summary = review_pr(review_prompt, pr_body, pr_title, preferences) - return summary + from ..site.website import site as site_blueprint + app.register_blueprint(site_blueprint) + return app if __name__ == "__main__": + app = create_app() app.run(port=3000, debug=True) diff --git a/src/github/github_app.py b/src/github/github_app.py index 5bf46d6..11ce9d7 100644 --- a/src/github/github_app.py +++ b/src/github/github_app.py @@ -7,7 +7,12 @@ load_dotenv() APP_ID = os.getenv("APP_ID") -PRIVATE_KEY = open("../../hack-review.pem").read() +# Construct the path to the private key file relative to this script +dir_path = os.path.dirname(os.path.realpath(__file__)) +private_key_path = os.path.join(dir_path, "..", "..", "hack-review.pem") + +with open(private_key_path, "r") as f: + PRIVATE_KEY = f.read() def generate_jwt(): payload = { diff --git a/src/github/preferences.py b/src/github/preferences.py index 04fb845..3f4c804 100644 --- a/src/github/preferences.py +++ b/src/github/preferences.py @@ -7,13 +7,19 @@ API_KEY = os.getenv("API_KEY") MODEL = os.getenv("MODEL") -client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1") +if API_KEY: + client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1") +else: + client = None def extract_and_save_preference(comment_body): """ Analyzes a comment to see if it contains a project preference, and if so, saves it to the data/preferences.md file. """ + if not client: + return "" + try: system_prompt = """You are an AI assistant tasked with identifying project-specific preferences from user comments. Analyze the following comment. If it contains a clear preference, convention, or rule for the project's codebase, please summarize it in a single, concise sentence. @@ -34,7 +40,10 @@ def extract_and_save_preference(comment_body): preference = response.choices[0].message.content.strip() if "NO_PREFERENCE" not in preference: - with open("../../data/preferences.md", "a") as f: + # Construct path relative to the project root + dir_path = os.path.dirname(os.path.realpath(__file__)) + preferences_path = os.path.join(dir_path, "..", "..", "data", "preferences.md") + with open(preferences_path, "a") as f: f.write(f"- {preference}\n") return f"Preference noted: {preference}" else: diff --git a/src/github/review.py b/src/github/review.py index b3824a3..eadc048 100644 --- a/src/github/review.py +++ b/src/github/review.py @@ -7,12 +7,19 @@ API_KEY = os.getenv("API_KEY") MODEL = os.getenv("MODEL") -client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1") +if API_KEY: + client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1") +else: + client = None def review_pr(review_prompt, pr_body, pr_title, preferences=""): + if not client: + return "The API_KEY is not configured." try: - with open("../../data/System_Prompt.md", "r") as system_prompt_file: + dir_path = os.path.dirname(os.path.realpath(__file__)) + system_prompt_path = os.path.join(dir_path, "..", "..", "data", "System_Prompt.md") + with open(system_prompt_path, "r") as system_prompt_file: system_prompt = system_prompt_file.read() except FileNotFoundError: print("Error: The file 'data/System_Prompt.md' was not found.") @@ -29,6 +36,8 @@ def review_pr(review_prompt, pr_body, pr_title, preferences=""): def review_comment(comment_body, diff_hunk=None): + if not client: + return "The API_KEY is not configured." system_prompt = "You are a helpful AI assistant. A user has mentioned you in a code review comment, please respond to them." if diff_hunk: user_prompt = f"The user commented:\n{comment_body}\n\nThe code they are commenting on is:\n{diff_hunk}" @@ -38,6 +47,8 @@ def review_comment(comment_body, diff_hunk=None): def generate_review(user_prompt, system_prompt): + if not client: + return "The API_KEY is not configured." try: response = client.chat.completions.create( model=MODEL, diff --git a/src/github/webhook.py b/src/github/webhook.py new file mode 100644 index 0000000..6a0b4c8 --- /dev/null +++ b/src/github/webhook.py @@ -0,0 +1,231 @@ +from flask import Blueprint, request, current_app +import logging +import requests +import os +from .github_app import get_installation_token +from .preferences import extract_and_save_preference +from .review import review_pr, review_comment +import re + +webhook = Blueprint('webhook', __name__) + +BOT_NAME = os.getenv("BOT_NAME") + +@webhook.post("/webhook") +def handle_webhook(): + event = request.headers.get("X-GitHub-Event") + body = request.json + + # Ignore comments made by the bot itself to prevent loops + sender = body.get("sender", {}).get("login") + if sender and sender.lower() == BOT_NAME.lower(): + logging.info(f"Ignoring event triggered by the bot itself ({sender}).") + return "Ignoring own event" + + if event == "issue_comment" and body["action"] == "created": + comment_body = body["comment"]["body"].strip() + pr_number = body["issue"]["number"] + owner = body["repository"]["owner"]["login"] + repo = body["repository"]["name"] + installation_id = body["installation"]["id"] + token = get_installation_token(installation_id) + + if comment_body == "/review": + handle_review_command(owner, repo, pr_number, token) + elif f"@{BOT_NAME.lower()}" in comment_body.lower(): + handle_issue_comment(owner, repo, pr_number, comment_body, token) + + elif event == "pull_request_review_comment" and body["action"] == "created": + if f"@{BOT_NAME.lower()}" in body["comment"]["body"].lower(): + installation_id = body["installation"]["id"] + token = get_installation_token(installation_id) + owner = body["repository"]["owner"]["login"] + repo = body["repository"]["name"] + pr_number = body["pull_request"]["number"] + comment_body = body["comment"]["body"] + comment_id = body["comment"]["id"] + diff_hunk = body["comment"]["diff_hunk"] + handle_review_comment( + owner, repo, pr_number, comment_body, comment_id, token, diff_hunk + ) + + return "Request received" + + +def handle_review_command(owner, repo, pr_number, token): + """ + Handles a command to trigger a full PR review. + """ + headers = {"Authorization": f"token {token}"} + + logging.info(f"Handling /review command for {owner}/{repo} PR #{pr_number}") + + try: + # Get PR data to find the head SHA, title, and body + pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" + pr_response = requests.get(pr_url, headers=headers) + pr_response.raise_for_status() # Check for errors + pr_data = pr_response.json() + + pr_body = pr_data.get("body", "") + pr_title = pr_data.get("title", "") + commit_id = pr_data.get("head", {}).get("sha") + + if not commit_id: + logging.error(f"Could not find head SHA for PR #{pr_number}") + return + + # This is the existing function that does the review + send_review(owner, repo, pr_number, pr_body, pr_title, token, commit_id) + logging.info(f"Successfully triggered review for PR #{pr_number}") + + except requests.exceptions.RequestException as e: + logging.error(f"Failed to fetch PR data for review. Error: {e}") + logging.error(f"Response body: {e.response.text if e.response else 'No response'}") + + +def handle_issue_comment(owner, repo, pr_number, comment_body, token): + if f"@{BOT_NAME.lower()}" not in comment_body.lower(): + logging.warning("handle_issue_comment called without a bot mention. Skipping.") + return + + headers = {"Authorization": f"token {token}"} + + logging.info(f"Handling issue comment for {owner}/{repo} PR #{pr_number}") + + preference_response = extract_and_save_preference(comment_body) + review_response = review_comment(comment_body) + + if preference_response: + response_body = f"{preference_response}\n\n---\n\n{review_response}" + else: + response_body = review_response + + comment_url = ( + f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" + ) + + try: + api_response = requests.post(comment_url, headers=headers, json={"body": response_body}) + api_response.raise_for_status() # Raise an exception for bad status codes + logging.info(f"Successfully posted comment to PR #{pr_number}. Status: {api_response.status_code}") + except requests.exceptions.RequestException as e: + logging.error(f"Failed to post comment to PR #{pr_number}. Error: {e}") + logging.error(f"Response body: {e.response.text if e.response else 'No response'}") + + +def handle_review_comment( + owner, repo, pr_number, comment_body, comment_id, token, diff_hunk +): + if f"@{BOT_NAME.lower()}" not in comment_body.lower(): + logging.warning("handle_review_comment called without a bot mention. Skipping.") + return + + headers = {"Authorization": f"token {token}"} + + logging.info(f"Handling review comment for {owner}/{repo} PR #{pr_number}") + + preference_response = extract_and_save_preference(comment_body) + review_response = review_comment(comment_body, diff_hunk) + + if preference_response: + response_body = f"{preference_response}\n\n---\n\n{review_response}" + else: + response_body = review_response + + comment_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments/{comment_id}/replies" + + try: + api_response = requests.post(comment_url, headers=headers, json={"body": response_body}) + api_response.raise_for_status() + logging.info(f"Successfully posted reply to comment {comment_id} in PR #{pr_number}. Status: {api_response.status_code}") + except requests.exceptions.RequestException as e: + logging.error(f"Failed to post reply to comment {comment_id} in PR #{pr_number}. Error: {e}") + logging.error(f"Response body: {e.response.text if e.response else 'No response'}") + + +def send_review(owner, repo, pr_number, pr_body, pr_title, token, commit_id): + headers = {"Authorization": f"token {token}"} + + # Get PR data to find the head SHA, title, and body + pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" + pr_response = requests.get(pr_url, headers=headers) + pr_response.raise_for_status() # Check for errors + pr_data = pr_response.json() + + pr_body = pr_data.get("body", "") + pr_title = pr_data.get("title", "") + commit_id = pr_data.get("head", {}).get("sha") + + if not commit_id: + logging.error(f"Could not find head SHA for PR #{pr_number}") + return + + # Fetch files from the pull request + files_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files" + files = requests.get(files_url, headers=headers).json() + + try: + dir_path = os.path.dirname(os.path.realpath(__file__)) + preferences_path = os.path.join(dir_path, "..", "..", "data", "preferences.md") + with open(preferences_path, "r") as f: + preferences = f.read() + except FileNotFoundError: + preferences = "" + + # Generate summary of changes + summary = summarize_changes(files, pr_body, pr_title, preferences) + + # Post review comments + post_review_comments(owner, repo, pr_number, commit_id, summary, headers) + + +def post_review_comments(owner, repo, pr_number, commit_id, summary, headers): + comments = parse_review_comments(summary) + for comment in comments: + comment_url = ( + f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments" + ) + payload = { + "body": comment["comment"], + "commit_id": commit_id, + "path": comment["file"], + "line": comment["line"], + } + requests.post(comment_url, headers=headers, json=payload) + + +def parse_review_comments(summary): + """ + Parses the review summary to extract individual comments. + """ + # The regex looks for a pattern like: `* **app.py:42** - The comment text.` + comment_pattern = re.compile(r"\*\s*\*\*(.+?):(\d+)\*\* - (.+)") + comments = [] + for line in summary.splitlines(): + match = comment_pattern.match(line) + if match: + comments.append( + { + "file": match.group(1), + "line": int(match.group(2)), + "comment": match.group(3), + } + ) + return comments + + +def summarize_changes(files, pr_body, pr_title, preferences): + """ + Generates a summary of the changes in a pull request. + """ + keys_to_remove = ["sha", "blob_url", "raw_url", "contents_url"] + for file in files: + for key in keys_to_remove: + file.pop(key, None) + + review_prompt = "" + for file in files: + review_prompt += f"\nFile Name: \n{file['filename']} \nStatus: {file['status']}\nAdditions: {file['additions']}\nDeletions: {file['deletions']}\nChanges: {file['changes']}\nDiff: {file['patch']}" + summary = review_pr(review_prompt, pr_body, pr_title, preferences) + return summary diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..1b8d12b --- /dev/null +++ b/src/models.py @@ -0,0 +1,13 @@ +from .extensions import db +from flask_login import UserMixin + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password = db.Column(db.String(150), nullable=False) + keys = db.relationship('APIKey', backref='user', lazy=True) + +class APIKey(db.Model): + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(150), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) diff --git a/src/site/static/style.css b/src/site/static/style.css new file mode 100644 index 0000000..0be14a8 --- /dev/null +++ b/src/site/static/style.css @@ -0,0 +1,98 @@ +:root { + --bg-color: #FFF3EB; + --primary-color: #EC3750; + --heading-color: #4D000B; + --text-color: #A67E85; + --primary-hover-color: #D62640; + --surface-color: #FFFFFF; + --border-color: #F0D4D8; +} + +body { + background-color: var(--bg-color) !important; /* Use !important to ensure override */ + color: var(--text-color); +} + +h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { + color: var(--heading-color); +} + +a { + color: var(--primary-color); +} + +a:hover { + color: var(--primary-hover-color); +} + +.btn { + border-radius: 20px; +} + +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: #FFFFFF; +} + +.btn-primary:hover { + background-color: var(--primary-hover-color); + border-color: var(--primary-hover-color); + color: #FFFFFF; +} + +.card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 15px; + box-shadow: 0 4px 6px rgba(0,0,0,0.05); +} + +.form-control { + border-radius: 10px; + border: 1px solid var(--border-color); + background-color: var(--surface-color); + color: var(--text-color); +} + +.form-control:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.25rem rgba(236, 55, 80, 0.25); /* primary-color with alpha */ +} + +.navbar.bg-light { + background-color: var(--surface-color) !important; + border-bottom: 1px solid var(--border-color); +} + +.navbar-brand { + color: var(--heading-color) !important; + font-weight: bold; +} + +.nav-link { + color: var(--text-color) !important; +} + +.nav-link:hover { + color: var(--primary-color) !important; +} + +.alert { + border-radius: 10px; +} +.alert-warning { + background-color: #fff8e1; + border-color: #ffecb3; + color: #6d4c41; +} +.alert-danger { + background-color: #fce4ec; + border-color: #f8bbd0; + color: #ad1457; +} +.alert-success { + background-color: #e8f5e9; + border-color: #c8e6c9; + color: #1b5e20; +} diff --git a/src/site/templates/base.html b/src/site/templates/base.html new file mode 100644 index 0000000..54a5d0c --- /dev/null +++ b/src/site/templates/base.html @@ -0,0 +1,55 @@ + + + + + + {% block title %}Hack Review{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + + diff --git a/src/site/templates/home.html b/src/site/templates/home.html new file mode 100644 index 0000000..9ddae3f --- /dev/null +++ b/src/site/templates/home.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} + +{% block title %}Home{% endblock %} + +{% block content %} +

Usage Stats

+ {% if stats %} +
+
+
API Usage
+

Total Requests: {{ stats.totalRequests }}

+

Total Tokens: {{ stats.totalTokens }}

+

Total Prompt Tokens: {{ stats.totalPromptTokens }}

+

Total Completion Tokens: {{ stats.totalCompletionTokens }}

+
+
+ {% else %} +

No stats to display. Please add your API key on the settings page.

+ {% endif %} +{% endblock %} diff --git a/src/site/templates/login.html b/src/site/templates/login.html new file mode 100644 index 0000000..cdca2b3 --- /dev/null +++ b/src/site/templates/login.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} + +{% block title %}Login{% endblock %} + +{% block content %} +

Login

+
+
+ + +
+
+ + +
+ +
+

+ Don't have an account? Sign up here. +

+{% endblock %} diff --git a/src/site/templates/settings.html b/src/site/templates/settings.html new file mode 100644 index 0000000..a2306e8 --- /dev/null +++ b/src/site/templates/settings.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} + +{% block title %}Settings{% endblock %} + +{% block content %} +

Settings

+ +
+
+
Add New API Key
+
+
+ + +
+
+
+
+ +
+
+
Your API Keys
+ {% if keys %} + + {% else %} +

You haven't added any API keys yet.

+ {% endif %} +
+
+{% endblock %} diff --git a/src/site/templates/signup.html b/src/site/templates/signup.html new file mode 100644 index 0000000..65ded82 --- /dev/null +++ b/src/site/templates/signup.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} + +{% block title %}Sign Up{% endblock %} + +{% block content %} +

Sign Up

+
+
+ + +
+
+ + +
+ +
+

+ Already have an account? Login here. +

+{% endblock %} diff --git a/src/site/website.py b/src/site/website.py new file mode 100644 index 0000000..a34ba50 --- /dev/null +++ b/src/site/website.py @@ -0,0 +1,112 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_required, logout_user, current_user, login_user +from werkzeug.security import generate_password_hash, check_password_hash +import requests +import os +from ..extensions import db, login_manager + +from ..models import User, APIKey + +site_path = os.path.dirname(os.path.abspath(__file__)) +static_folder = os.path.join(site_path, 'static') +site = Blueprint('site', __name__, template_folder='templates', static_folder=static_folder, static_url_path='/site/static') + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +@site.route('/') +@login_required +def home(): + if not current_user.keys: + flash('Please add an API key on the settings page to view stats.', 'warning') + return render_template('home.html', stats=None) + + # Use the first API key for stats + api_key_to_use = current_user.keys[0].key + + try: + headers = {'Authorization': f'Bearer {api_key_to_use}'} + response = requests.get('https://ai.hackclub.com/proxy/v1/stats', headers=headers) + response.raise_for_status() # Raise an exception for bad status codes + stats = response.json() + except requests.exceptions.RequestException as e: + flash(f'Error fetching stats: {e}', 'danger') + stats = None + return render_template('home.html', stats=stats) + +@site.route('/settings', methods=['GET', 'POST']) +@login_required +def settings(): + if request.method == 'POST': + api_key = request.form.get('api_key') + if api_key: + # Check if key already exists for this user + existing_key = APIKey.query.filter_by(key=api_key, user_id=current_user.id).first() + if not existing_key: + new_key = APIKey(key=api_key, user_id=current_user.id) + db.session.add(new_key) + db.session.commit() + flash('API Key added successfully!', 'success') + else: + flash('This API Key has already been added.', 'info') + else: + flash('API Key cannot be empty.', 'warning') + return redirect(url_for('site.settings')) + + keys = current_user.keys + return render_template('settings.html', keys=keys) + +@site.route('/delete_key/', methods=['POST']) +@login_required +def delete_key(key_id): + key_to_delete = APIKey.query.get_or_404(key_id) + if key_to_delete.user_id != current_user.id: + # Unauthorized + return redirect(url_for('site.settings')) + + db.session.delete(key_to_delete) + db.session.commit() + flash('API Key deleted successfully!', 'success') + return redirect(url_for('site.settings')) + +@site.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('site.home')) + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + user = User.query.filter_by(username=username).first() + if user and check_password_hash(user.password, password): + login_user(user, remember=True) + return redirect(url_for('site.home')) + else: + flash('Login Unsuccessful. Please check username and password', 'danger') + return render_template('login.html') + +@site.route('/signup', methods=['GET', 'POST']) +def signup(): + if current_user.is_authenticated: + return redirect(url_for('site.home')) + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + existing_user = User.query.filter_by(username=username).first() + if existing_user: + flash('Username already exists. Please choose a different one.', 'danger') + return redirect(url_for('site.signup')) + + hashed_password = generate_password_hash(password, method='pbkdf2:sha256') + new_user = User(username=username, password=hashed_password) + db.session.add(new_user) + db.session.commit() + flash('Account created successfully! You can now log in.', 'success') + return redirect(url_for('site.login')) + return render_template('signup.html') + +@site.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('site.login'))