From 46c4fdc7e47138cf265f4f990c9082b0635dbc58 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 13:59:08 -0400 Subject: [PATCH 01/68] Fixed code so the server can run --- forum/__init__.py | 4 ++-- forum/app.py | 2 +- forum/routes.py | 4 ++-- run.sh | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index c10b0f3..b3498d7 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,5 +1,5 @@ from flask import Flask -from forum.routes import rt +from .routes import rt def create_app(): """Construct the core application.""" @@ -11,7 +11,7 @@ def create_app(): # etc app.register_blueprint(rt) # Set globals - from forum.models import db + from .models import db db.init_app(app) with app.app_context(): diff --git a/forum/app.py b/forum/app.py index 4d8a828..d49ec8b 100644 --- a/forum/app.py +++ b/forum/app.py @@ -1,7 +1,7 @@ from flask import render_template from flask_login import LoginManager -from forum.models import Subforum, db, User +from .models import Subforum, db, User from . import create_app app = create_app() diff --git a/forum/routes.py b/forum/routes.py index 75993e5..f594b2c 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,8 +3,8 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from forum.models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error -from forum.user import username_taken, email_taken, valid_username +from .models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .user import username_taken, email_taken, valid_username ## # This file needs to be broken up into several, to make the project easier to work on. diff --git a/run.sh b/run.sh index a39697a..617e279 100755 --- a/run.sh +++ b/run.sh @@ -3,4 +3,5 @@ export SECRET_KEY="kristofer" # honcho start # you can ALSO or RATHER use the following command to run the app -cd ./forum; flask run +export FLASK_APP=forum.app +flask run From fc89bb41dbc7172864df6fd64c08833e71a65bf0 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 14:01:17 -0400 Subject: [PATCH 02/68] set port to 8000 --- run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.sh b/run.sh index 617e279..9a2ff54 100755 --- a/run.sh +++ b/run.sh @@ -4,4 +4,4 @@ export SECRET_KEY="kristofer" # you can ALSO or RATHER use the following command to run the app export FLASK_APP=forum.app -flask run +flask run --port 8000 From 7779317c64be1b8cda0d89110399485b519747bd Mon Sep 17 00:00:00 2001 From: emoyer Date: Mon, 6 Apr 2026 14:16:57 -0400 Subject: [PATCH 03/68] Added comprehension comments --- PROJECT_FLOW.md | 304 ++++++++++++++++++++++++++++++++++++++++++++++ Procfile | 1 - forum/__init__.py | 10 +- forum/app.py | 12 +- forum/models.py | 28 +++-- forum/routes.py | 32 +++-- 6 files changed, 356 insertions(+), 31 deletions(-) create mode 100644 PROJECT_FLOW.md delete mode 100644 Procfile diff --git a/PROJECT_FLOW.md b/PROJECT_FLOW.md new file mode 100644 index 0000000..2a00379 --- /dev/null +++ b/PROJECT_FLOW.md @@ -0,0 +1,304 @@ +# CircusCircus Project Flow + +This document explains how the current project fits together at two levels: +- quick overview for orientation +- deeper walkthrough of the runtime logic and data flow + +It is written so someone new to Flask can follow the project without reading all source files first. + +## Quick Overview + +- Tech stack: Flask + Flask-Login + Flask-SQLAlchemy + Jinja templates. +- Entry point: `forum/app.py` creates the running app and root route. +- App factory: `forum/__init__.py` builds and configures the Flask app. +- Configuration: `config.py` provides secret key and SQLAlchemy settings. +- Data models: `forum/models.py` defines User, Post, Subforum, Comment. +- Request handlers: `forum/routes.py` handles auth, browsing, posting, comments. +- Account helpers: `forum/user.py` contains username/password utility checks. +- Templates: `forum/templates/` render pages for index, subforums, posts, login. +- Startup script: `run.sh` runs the app using Flask. + +## How Startup Works + +1. Flask starts with module `forum.app` (configured by `FLASK_APP`). +2. `forum/app.py` calls `create_app()` from `forum/__init__.py`. +3. `create_app()`: + - creates Flask app + - loads `Config` from `config.py` + - registers blueprint `rt` from `forum/routes.py` + - initializes SQLAlchemy via `db.init_app(app)` + - creates DB tables with `db.create_all()` inside app context +4. Back in `forum/app.py`: + - Flask-Login is configured + - `user_loader` is registered to restore users from session IDs + - tables are ensured again and initial subforums are seeded if empty + +Note: table creation happens in both `forum/__init__.py` and `forum/app.py`. That is safe, but redundant. + +## Beginner Map: What You Are Looking At + +If you open this project and feel lost, use this sequence: + +1. Open `config.py` to see app-wide settings. +2. Open `forum/__init__.py` to see how Flask is created and wired. +3. Open `forum/app.py` to see login setup, seed data setup, and the home route. +4. Open `forum/routes.py` to see the main user actions (login, browse, post, comment). +5. Open `forum/models.py` to understand what data is stored and how tables connect. +6. Open `forum/templates/` to see what each route renders. + +This order moves from "how app starts" to "what app does" to "how page looks". + +## URL and Request Flow + +### Root and browsing + +- `GET /` in `forum/app.py`: + - fetches top-level subforums (`parent_id is None`) + - renders `subforums.html` + +- `GET /subforum?sub=` in `forum/routes.py`: + - loads the target subforum + - loads recent posts for that subforum (up to 50, newest first) + - loads child subforums + - builds breadcrumb HTML path with `generateLinkPath()` + - renders `subforum.html` + +- `GET /viewpost?post=` in `forum/routes.py`: + - loads post and comments (newest comments first) + - builds breadcrumb path from post's subforum + - renders `viewpost.html` + +### Authentication + +- `POST /action_login`: + - reads username/password from form + - finds user by username + - verifies hash with `user.check_password()` + - logs in with Flask-Login or returns login page with errors + +- `GET /action_logout` (login required): + - logs current user out + - redirects to root + +- `POST /action_createaccount`: + - validates username format and uniqueness + - validates email uniqueness + - creates `User` (password stored as hash) + - auto-logs in and redirects to root + +### Content creation + +- `GET /addpost?sub=` (login required): + - verifies subforum exists + - renders `createpost.html` + +- `POST /action_post?sub=` (login required): + - validates title/content length + - creates `Post` + - links post to current user and subforum + - commits and redirects to `viewpost` + +- `POST/GET /action_comment?post=` (login required): + - verifies post exists + - creates `Comment` + - links comment to current user and post + - commits and redirects to `viewpost` + +## Deep Logic Walkthrough + +This section explains how the system behaves internally, not just which files exist. + +### 1. App factory and global objects + +- `db` is created once in `forum/models.py` as a global SQLAlchemy object. +- `create_app()` in `forum/__init__.py` binds that `db` object to the Flask app instance. +- Route registration happens by attaching the routes blueprint (`rt`) to the app. +- Flask-Login setup happens in `forum/app.py`, where `LoginManager` is attached to the app. + +Result: every route can use `db`, model queries, and `current_user` with one shared app context. + +### 2. Database initialization and seed logic + +- On startup, tables are created if missing. +- Then the app checks whether any `Subforum` rows exist. +- If none exist, `init_site()` seeds a default category tree. +- `add_subforum()` avoids duplicate titles at the same tree level. + +Result: first run creates a usable forum structure automatically. + +### 3. Authentication lifecycle + +- Account creation stores a hashed password only. +- Login route checks plain password against hash. +- On success, Flask-Login stores user identity in session. +- On later requests, `load_user(userid)` converts session ID back into a `User` row. +- Logout clears that session identity. + +Result: auth state is cookie/session based, with DB lookup per request as needed. + +### 4. Read flow for forum pages + +- Forum index route reads top-level subforums. +- Subforum route reads: + - one subforum by ID + - posts under that subforum + - direct child subforums + - breadcrumb path text +- View-post route reads: + - one post by ID + - comments for that post + - breadcrumb path from that post's subforum + +Result: each page query is focused and template-driven. + +### 5. Write flow for posts and comments + +- Create-post route validates title/content length. +- If invalid, template is re-rendered with error messages. +- If valid, a `Post` is created and attached to: + - current user + - selected subforum +- Create-comment route does the same pattern for `Comment`: + - verify post exists + - create comment + - attach to current user and post +- Each write ends with `db.session.commit()` and redirect. + +Result: writes are simple transaction-style operations with immediate redirect. + +### 6. Why relationships are used heavily + +- Instead of manually assigning every foreign key field, code often appends objects to relationships. +- Example pattern: + - `user.posts.append(post)` + - `subforum.posts.append(post)` +- SQLAlchemy fills key IDs and persists links on commit. + +Result: route code stays short and object-oriented. + +## End-to-End Request Trace Examples + +These traces show exactly what happens in common user actions. + +### Trace A: User signs up, then lands on home page + +1. Browser submits signup form to `POST /action_createaccount`. +2. Route reads username, email, password from `request.form`. +3. Helper checks run: + - username format + - username uniqueness + - email uniqueness +4. If any check fails: + - route renders login template with error list + - DB is unchanged +5. If checks pass: + - `User` object created (password hashed) + - user row committed + - user logged in with Flask-Login + - redirect to `/` +6. `GET /` loads top-level subforums and renders index template. + +### Trace B: Logged-in user creates a post + +1. User opens `GET /addpost?sub=`. +2. Route confirms target subforum exists. +3. Create-post form renders. +4. User submits form to `POST /action_post?sub=`. +5. Route validates title/content length. +6. If invalid, route re-renders form with validation messages. +7. If valid: + - `Post` object is created + - post attached to selected subforum and current user + - session commit persists post row and foreign keys + - redirect to `GET /viewpost?post=` +8. Post detail page queries post/comments and renders. + +### Trace C: Logged-in user comments on a post + +1. User submits comment form to `POST /action_comment?post=`. +2. Route verifies post exists. +3. Route creates `Comment` with content and current timestamp. +4. Comment is linked to current user and target post. +5. Commit persists comment row and keys. +6. Redirect goes back to `GET /viewpost?post=`. +7. Page reload now includes the new comment at top (newest first). + +## How to Read Logic Quickly During Maintenance + +When trying to understand a behavior, follow this mini-checklist: + +1. Identify route function handling the URL. +2. List which model queries it runs. +3. Check which helper validators it calls. +4. Check what template it renders or redirect it returns. +5. Confirm where commit happens for data writes. + +Using this method keeps debugging predictable across the whole codebase. + +## Data Model and Relationships + +Defined in `forum/models.py`: + +- `User` + - fields: id, username, password_hash, email, admin + - relationships: one-to-many with Post and Comment + - methods: `check_password()` + +- `Subforum` + - fields: id, title, description, parent_id, hidden + - relationships: + - self-referential child subforums via `subforums` + - one-to-many with Post + +- `Post` + - fields: id, title, content, user_id, subforum_id, postdate + - relationships: one-to-many with Comment + - helper: `get_time_string()` for relative-time labels + +- `Comment` + - fields: id, content, postdate, user_id, post_id + - helper: `get_time_string()` for relative-time labels + +## Validation and Utility Logic + +From `forum/user.py` and `forum/models.py`: + +- Username regex allows 4-40 characters from `[a-zA-Z0-9!@#%&]`. +- Password regex allows 6-40 characters from same set. +- `username_taken()` and `email_taken()` query existing users. +- `valid_title()` and `valid_content()` enforce post size bounds. +- `error()` returns a simple red HTML error string. +- `generateLinkPath()` builds breadcrumb HTML by walking up parent subforums. + +## Session and Login State + +- Flask-Login tracks authenticated users in session cookies. +- `load_user(userid)` in `forum/app.py` maps session user IDs to `User` rows. +- Routes requiring auth use `@login_required`. + +## Templates and Rendering Role + +- `subforums.html`: forum index page +- `subforum.html`: single subforum view + child subforums + post list +- `viewpost.html`: post detail + comments +- `createpost.html`: post creation form +- `login.html`: login/signup form +- `layout.html` and `header.html`: shared structure and navigation + +## Important Notes About Current Logic + +- Redundant DB initialization: `db.create_all()` is called in two places. +- Error responses are plain HTML snippets, not dedicated error templates. +- Some routes trust query parameters exist and are valid integers. +- Relative-time cache fields (`lastcheck`, `savedresponce`) are in-memory only and reset per process. + +## Practical Mental Model + +Think of the app as four layers: + +1. Config layer (`config.py`): constants for app and database. +2. App wiring (`forum/__init__.py`, `forum/app.py`): create app, attach DB/login, seed initial data. +3. Domain layer (`forum/models.py`, `forum/user.py`): schema, validation helpers, utility functions. +4. Delivery layer (`forum/routes.py` + templates): map HTTP requests to queries, updates, and rendered HTML. + +When debugging, start at the route, then trace into model queries and helper functions, then verify template rendering inputs. diff --git a/Procfile b/Procfile deleted file mode 100644 index e8ccc30..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn forum.app:app diff --git a/forum/__init__.py b/forum/__init__.py index c10b0f3..184e682 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -5,17 +5,15 @@ def create_app(): """Construct the core application.""" app = Flask(__name__, instance_relative_config=False) app.config.from_object('config.Config') - # I think more blueprints might be used to break routes up into things like - # post_routes - # subforum_routes - # etc + # Register the main routes blueprint. app.register_blueprint(rt) - # Set globals + + # Initialize the shared database object on this app. from forum.models import db db.init_app(app) with app.app_context(): - # Add some routes + # Create tables on startup so the app can run against a fresh database. db.create_all() return app diff --git a/forum/app.py b/forum/app.py index 4d8a828..2c093a8 100644 --- a/forum/app.py +++ b/forum/app.py @@ -3,14 +3,17 @@ from flask_login import LoginManager from forum.models import Subforum, db, User -from . import create_app +from forum import create_app +# Build the Flask app using the package factory. app = create_app() +# Simple metadata used by the templates and app config. app.config['SITE_NAME'] = 'Schooner' app.config['SITE_DESCRIPTION'] = 'a schooner forum' app.config['FLASK_DEBUG'] = 1 def init_site(): + # Create the default forum structure on first run. print("creating initial subforums") admin = add_subforum("Forum", "Announcements, bug reports, and general discussion about the forum belongs here") add_subforum("Announcements", "View forum announcements here",admin) @@ -19,6 +22,7 @@ def init_site(): add_subforum("Other", "Discuss other things here") def add_subforum(title, description, parent=None): + # Avoid duplicate subforums at the same level. sub = Subforum(title, description) if parent: for subforum in parent.subforums: @@ -35,20 +39,24 @@ def add_subforum(title, description, parent=None): db.session.commit() return sub +# Flask-Login needs a loader so it can restore the current user from the session. login_manager = LoginManager() login_manager.init_app(app) @login_manager.user_loader def load_user(userid): + # Look up the full User row for the stored session ID. return User.query.get(userid) with app.app_context(): - db.create_all() # TODO this may be redundant + # Create tables if needed, then seed the database the first time it runs. + db.create_all() if not Subforum.query.all(): init_site() @app.route('/') def index(): + # Show only top-level subforums on the home page. subforums = Subforum.query.filter(Subforum.parent_id == None).order_by(Subforum.id) return render_template("subforums.html", subforums=subforums) diff --git a/forum/models.py b/forum/models.py index 8add9ae..6766044 100644 --- a/forum/models.py +++ b/forum/models.py @@ -3,13 +3,14 @@ from flask_login import UserMixin import datetime -# create db here so it can be imported (with the models) into the App object. +# Shared SQLAlchemy object used by the app factory and all models. from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() -#OBJECT MODELS +# Database models class User(UserMixin, db.Model): + # Store account information and ownership of posts/comments. id = db.Column(db.Integer, primary_key=True) username = db.Column(db.Text, unique=True) password_hash = db.Column(db.Text) @@ -19,13 +20,17 @@ class User(UserMixin, db.Model): comments = db.relationship("Comment", backref="user") def __init__(self, email, username, password): + # Save the hashed password instead of the plain text password. self.email = email self.username = username self.password_hash = generate_password_hash(password) + def check_password(self, password): + # Compare a password guess against the stored hash. return check_password_hash(self.password_hash, password) class Post(db.Model): + # Store one forum post and link it to a user and subforum. id = db.Column(db.Integer, primary_key=True) title = db.Column(db.Text) content = db.Column(db.Text) @@ -34,16 +39,17 @@ class Post(db.Model): subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) postdate = db.Column(db.DateTime) - #cache stuff + # Simple in-memory cache for human-readable time labels. lastcheck = None savedresponce = None + def __init__(self, title, content, postdate): self.title = title self.content = content self.postdate = postdate + def get_time_string(self): - #this only needs to be calculated every so often, not for every request - #this can be a rudamentary chache + # Only recalculate the label every 30 seconds. now = datetime.datetime.now() if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: self.lastcheck = now @@ -53,7 +59,6 @@ def get_time_string(self): diff = now - self.postdate seconds = diff.total_seconds() - print(seconds) if seconds / (60 * 60 * 24 * 30) > 1: self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" elif seconds / (60 * 60 * 24) > 1: @@ -68,6 +73,7 @@ def get_time_string(self): return self.savedresponce class Subforum(db.Model): + # Represent a forum category and its optional child subforums. id = db.Column(db.Integer, primary_key=True) title = db.Column(db.Text, unique=True) description = db.Column(db.Text) @@ -76,11 +82,13 @@ class Subforum(db.Model): posts = db.relationship("Post", backref="subforum") path = None hidden = db.Column(db.Boolean, default=False) + def __init__(self, title, description): self.title = title self.description = description class Comment(db.Model): + # Store a comment attached to a post and authored by a user. id = db.Column(db.Integer, primary_key=True) content = db.Column(db.Text) postdate = db.Column(db.DateTime) @@ -89,12 +97,13 @@ class Comment(db.Model): lastcheck = None savedresponce = None + def __init__(self, content, postdate): self.content = content self.postdate = postdate + def get_time_string(self): - #this only needs to be calculated every so often, not for every request - #this can be a rudamentary chache + # Only recalculate the label every 30 seconds. now = datetime.datetime.now() if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: self.lastcheck = now @@ -133,9 +142,10 @@ def generateLinkPath(subforumid): return link -#Post checks +# Post validation helpers def valid_title(title): return len(title) > 4 and len(title) < 140 + def valid_content(content): return len(content) > 10 and len(content) < 5000 diff --git a/forum/routes.py b/forum/routes.py index 75993e5..43711eb 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -6,14 +6,14 @@ from forum.models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error from forum.user import username_taken, email_taken, valid_username -## -# This file needs to be broken up into several, to make the project easier to work on. -## +# Route handlers for login, browsing, and content creation. +# The app is small enough to keep in one blueprint for now. rt = Blueprint('routes', __name__, template_folder='templates') @rt.route('/action_login', methods=['POST']) def action_login(): + # Read login form values and authenticate the user. username = request.form['username'] password = request.form['password'] user = User.query.filter(User.username == username).first() @@ -29,12 +29,13 @@ def action_login(): @login_required @rt.route('/action_logout') def action_logout(): - #todo + # End the current session and send the user back home. logout_user() return redirect("/") @rt.route('/action_createaccount', methods=['POST']) def action_createaccount(): + # Validate signup data, create the user, then log them in. username = request.form['username'] password = request.form['password'] email = request.form['email'] @@ -42,7 +43,7 @@ def action_createaccount(): retry = False if username_taken(username): errors.append("Username is already taken!") - retry=True + retry = True if email_taken(email): errors.append("An account already exists with this email!") retry = True @@ -65,25 +66,27 @@ def action_createaccount(): @rt.route('/subforum') def subforum(): + # Show one subforum, its posts, and its child subforums. subforum_id = int(request.args.get("sub")) subforum = Subforum.query.filter(Subforum.id == subforum_id).first() if not subforum: return error("That subforum does not exist!") posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) - if not subforum.path: - subforumpath = generateLinkPath(subforum.id) + subforumpath = subforum.path or generateLinkPath(subforum.id) subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) @rt.route('/loginform') def loginform(): + # Render the shared login and signup page. return render_template("login.html") @login_required @rt.route('/addpost') def addpost(): + # Show the new post form for the selected subforum. subforum_id = int(request.args.get("sub")) subforum = Subforum.query.filter(Subforum.id == subforum_id).first() if not subforum: @@ -93,18 +96,20 @@ def addpost(): @rt.route('/viewpost') def viewpost(): + # Show one post and its comments. postid = int(request.args.get("post")) post = Post.query.filter(Post.id == postid).first() if not post: return error("That post does not exist!") - if not post.subforum.path: - subforumpath = generateLinkPath(post.subforum.id) - comments = Comment.query.filter(Comment.post_id == postid).order_by(Comment.id.desc()) # no need for scalability now + subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) + # Newest comments appear first for easier reading. + comments = Comment.query.filter(Comment.post_id == postid).order_by(Comment.id.desc()) return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) @login_required @rt.route('/action_comment', methods=['POST', 'GET']) def comment(): + # Create a comment, attach it to the user and post, then save it. post_id = int(request.args.get("post")) post = Post.query.filter(Post.id == post_id).first() if not post: @@ -120,15 +125,16 @@ def comment(): @login_required @rt.route('/action_post', methods=['POST']) def action_post(): + # Validate a new post before saving it. subforum_id = int(request.args.get("sub")) subforum = Subforum.query.filter(Subforum.id == subforum_id).first() if not subforum: - return redirect(url_for("subforums")) + return redirect(url_for("index")) user = current_user title = request.form['title'] content = request.form['content'] - #check for valid posting + # Collect validation errors first so the form can be shown again if needed. errors = [] retry = False if not valid_title(title): @@ -138,7 +144,7 @@ def action_post(): errors.append("Post must be between 10 and 5000 characters long!") retry = True if retry: - return render_template("createpost.html",subforum=subforum, errors=errors) + return render_template("createpost.html", subforum=subforum, errors=errors) post = Post(title, content, datetime.datetime.now()) subforum.posts.append(post) user.posts.append(post) From bb3da420eabe98518d2e518f87904a89e241ed97 Mon Sep 17 00:00:00 2001 From: emoyer Date: Mon, 6 Apr 2026 14:20:58 -0400 Subject: [PATCH 04/68] Messed with flask --- run.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run.sh b/run.sh index a39697a..9a2ff54 100755 --- a/run.sh +++ b/run.sh @@ -3,4 +3,5 @@ export SECRET_KEY="kristofer" # honcho start # you can ALSO or RATHER use the following command to run the app -cd ./forum; flask run +export FLASK_APP=forum.app +flask run --port 8000 From f754b43a04f44342274c5888f7826d0e119f64f4 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 14:49:09 -0400 Subject: [PATCH 05/68] fixed problems --- run.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/run.sh b/run.sh index 9a2ff54..6c3896f 100755 --- a/run.sh +++ b/run.sh @@ -3,5 +3,6 @@ export SECRET_KEY="kristofer" # honcho start # you can ALSO or RATHER use the following command to run the app -export FLASK_APP=forum.app -flask run --port 8000 +cd ./forum; flask run --port 8000 +# Put this into terminal to run +# ./run.sh From f06d7f06611ba4dd552a55574d3b1b3bfc2daea7 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 14:49:38 -0400 Subject: [PATCH 06/68] added extra commint --- run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.sh b/run.sh index 6c3896f..d979753 100755 --- a/run.sh +++ b/run.sh @@ -4,5 +4,5 @@ export SECRET_KEY="kristofer" # you can ALSO or RATHER use the following command to run the app cd ./forum; flask run --port 8000 -# Put this into terminal to run +# Put this into terminal to run, make sure in CircusCircus directory # ./run.sh From e99f4e4b68d87c77011f4422a1cbe298e09ea244 Mon Sep 17 00:00:00 2001 From: emoyer Date: Mon, 6 Apr 2026 14:53:19 -0400 Subject: [PATCH 07/68] pull --- forum/__init__.py | 2 +- forum/app.py | 10 ++++++++++ forum/routes.py | 4 ++-- run.sh | 5 ++--- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index 184e682..ad457d4 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,5 +1,5 @@ from flask import Flask -from forum.routes import rt +from .routes import rt def create_app(): """Construct the core application.""" diff --git a/forum/app.py b/forum/app.py index 2c093a8..770a961 100644 --- a/forum/app.py +++ b/forum/app.py @@ -1,3 +1,9 @@ +import os +import sys + +if __package__ in (None, ""): + # Allow running this file directly with `python forum/app.py`. + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from flask import render_template from flask_login import LoginManager @@ -61,5 +67,9 @@ def index(): return render_template("subforums.html", subforums=subforums) +if __name__ == "__main__": + app.run(debug=True, port=8000) + + diff --git a/forum/routes.py b/forum/routes.py index 43711eb..dc14a14 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,8 +3,8 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from forum.models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error -from forum.user import username_taken, email_taken, valid_username +from .models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .user import username_taken, email_taken, valid_username # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. diff --git a/run.sh b/run.sh index 9a2ff54..3f56c40 100755 --- a/run.sh +++ b/run.sh @@ -2,6 +2,5 @@ export SECRET_KEY="kristofer" # honcho start -# you can ALSO or RATHER use the following command to run the app -export FLASK_APP=forum.app -flask run --port 8000 +# Run the app directly from the package file. +python forum/app.py From f76a4f004a4c0b16046f99e110f9f2a17bd38176 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 15:04:28 -0400 Subject: [PATCH 08/68] want to fix main --- run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/run.sh b/run.sh index d979753..f07ceb4 100755 --- a/run.sh +++ b/run.sh @@ -6,3 +6,4 @@ export SECRET_KEY="kristofer" cd ./forum; flask run --port 8000 # Put this into terminal to run, make sure in CircusCircus directory # ./run.sh +#fix \ No newline at end of file From a9e8b15418c13c75e58b8780ea9ce3b758c25f5f Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 15:05:06 -0400 Subject: [PATCH 09/68] To have change back to fix main --- run.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/run.sh b/run.sh index f07ceb4..5f92d01 100755 --- a/run.sh +++ b/run.sh @@ -5,5 +5,4 @@ export SECRET_KEY="kristofer" # you can ALSO or RATHER use the following command to run the app cd ./forum; flask run --port 8000 # Put this into terminal to run, make sure in CircusCircus directory -# ./run.sh -#fix \ No newline at end of file +# ./run.sh \ No newline at end of file From 1c83de8516cdfba90f756208fc662b2186db7135 Mon Sep 17 00:00:00 2001 From: emoyer Date: Mon, 6 Apr 2026 15:14:21 -0400 Subject: [PATCH 10/68] Revert "pull" This reverts commit e99f4e4b68d87c77011f4422a1cbe298e09ea244. --- forum/__init__.py | 2 +- forum/app.py | 10 ---------- forum/routes.py | 4 ++-- run.sh | 5 +++-- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index ad457d4..184e682 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,5 +1,5 @@ from flask import Flask -from .routes import rt +from forum.routes import rt def create_app(): """Construct the core application.""" diff --git a/forum/app.py b/forum/app.py index 770a961..2c093a8 100644 --- a/forum/app.py +++ b/forum/app.py @@ -1,9 +1,3 @@ -import os -import sys - -if __package__ in (None, ""): - # Allow running this file directly with `python forum/app.py`. - sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from flask import render_template from flask_login import LoginManager @@ -67,9 +61,5 @@ def index(): return render_template("subforums.html", subforums=subforums) -if __name__ == "__main__": - app.run(debug=True, port=8000) - - diff --git a/forum/routes.py b/forum/routes.py index dc14a14..43711eb 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,8 +3,8 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error -from .user import username_taken, email_taken, valid_username +from forum.models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error +from forum.user import username_taken, email_taken, valid_username # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. diff --git a/run.sh b/run.sh index 3f56c40..9a2ff54 100755 --- a/run.sh +++ b/run.sh @@ -2,5 +2,6 @@ export SECRET_KEY="kristofer" # honcho start -# Run the app directly from the package file. -python forum/app.py +# you can ALSO or RATHER use the following command to run the app +export FLASK_APP=forum.app +flask run --port 8000 From 7af051b9b90306967ffdcf6a82175586a091fd03 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Apr 2026 15:49:53 -0400 Subject: [PATCH 11/68] allows running server or local --- run.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/run.sh b/run.sh index d979753..5f23fad 100755 --- a/run.sh +++ b/run.sh @@ -1,8 +1,11 @@ -# export PORT=5006 +#!/bin/bash export SECRET_KEY="kristofer" -# honcho start -# you can ALSO or RATHER use the following command to run the app -cd ./forum; flask run --port 8000 -# Put this into terminal to run, make sure in CircusCircus directory -# ./run.sh +if [ "$1" = "--server" ]; then + cd ./forum && flask run --host=0.0.0.0 --port=8000 +else + cd ./forum && flask run --port=8000 +fi + +# To run the server, use: ./run.sh --server +# To run the server in development mode, use: ./run.sh From 254d6cad08b9c75ee8589a7940a6215844e3e691 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Apr 2026 13:50:19 -0400 Subject: [PATCH 12/68] added foreignkey user_id --- forum/Subform.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 forum/Subform.py diff --git a/forum/Subform.py b/forum/Subform.py new file mode 100644 index 0000000..f250ebf --- /dev/null +++ b/forum/Subform.py @@ -0,0 +1,20 @@ +# Shared SQLAlchemy object used by the app factory and all models. +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + +class Subforum(db.Model): + # Represent a forum category and its optional child subforums. + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.Text, unique=True) + description = db.Column(db.Text) + subforums = db.relationship("Subforum") + parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) + posts = db.relationship("Post", backref="subforum") + path = None + hidden = db.Column(db.Boolean, default=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + + def __init__(self, title, description): + self.title = title + self.description = description \ No newline at end of file From d0397723f4b9d641d89b69cfbb952437fd33d9a4 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 14:11:24 -0400 Subject: [PATCH 13/68] Posts + comments --- forum/models.py | 83 +++---------------------------------------------- forum/post.py | 45 +++++++++++++++++++++++++++ forum/routes.py | 14 ++++----- 3 files changed, 56 insertions(+), 86 deletions(-) create mode 100644 forum/post.py diff --git a/forum/models.py b/forum/models.py index 6766044..bed2d05 100644 --- a/forum/models.py +++ b/forum/models.py @@ -17,7 +17,6 @@ class User(UserMixin, db.Model): email = db.Column(db.Text, unique=True) admin = db.Column(db.Boolean, default=False) posts = db.relationship("Post", backref="user") - comments = db.relationship("Comment", backref="user") def __init__(self, email, username, password): # Save the hashed password instead of the plain text password. @@ -29,48 +28,7 @@ def check_password(self, password): # Compare a password guess against the stored hash. return check_password_hash(self.password_hash, password) -class Post(db.Model): - # Store one forum post and link it to a user and subforum. - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text) - content = db.Column(db.Text) - comments = db.relationship("Comment", backref="post") - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - postdate = db.Column(db.DateTime) - - # Simple in-memory cache for human-readable time labels. - lastcheck = None - savedresponce = None - - def __init__(self, title, content, postdate): - self.title = title - self.content = content - self.postdate = postdate - - def get_time_string(self): - # Only recalculate the label every 30 seconds. - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - - return self.savedresponce + class Subforum(db.Model): # Represent a forum category and its optional child subforums. @@ -87,42 +45,9 @@ def __init__(self, title, description): self.title = title self.description = description -class Comment(db.Model): - # Store a comment attached to a post and authored by a user. - id = db.Column(db.Integer, primary_key=True) - content = db.Column(db.Text) - postdate = db.Column(db.DateTime) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - post_id = db.Column(db.Integer, db.ForeignKey("post.id")) - - lastcheck = None - savedresponce = None - - def __init__(self, content, postdate): - self.content = content - self.postdate = postdate - - def get_time_string(self): - # Only recalculate the label every 30 seconds. - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - return self.savedresponce +# Post is defined in post.py; imported here after db is ready to avoid +# circular imports while keeping Post in its own module. +from .post import Post # noqa: E402 def error(errormessage): return "" + errormessage + "" diff --git a/forum/post.py b/forum/post.py new file mode 100644 index 0000000..b305fab --- /dev/null +++ b/forum/post.py @@ -0,0 +1,45 @@ +import datetime +from .models import db + +class Post(db.Model): + # Store a forum post or a reply. Top-level posts have parent_id=None; + # replies point to their parent via parent_id. + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.Text, nullable=True) + content = db.Column(db.Text) + postdate = db.Column(db.DateTime) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id'), nullable=True) + parent_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) + replies = db.relationship("Post", backref=db.backref('parent_post', remote_side='Post.id')) + + # Simple in-memory cache for human-readable time labels. + lastcheck = None + savedresponse = None + + def __init__(self, content, postdate, title=None): + self.title = title + self.content = content + self.postdate = postdate + + def get_time_string(self): + # Recalculate every 30 seconds to avoid inaccurate time labels + now = datetime.datetime.now() + if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: + self.lastcheck = now + else: + return self.savedresponse + + diff = now - self.postdate + seconds = diff.total_seconds() + if seconds / (60 * 60 * 24 * 30) > 1: + self.savedresponse = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" + elif seconds / (60 * 60 * 24) > 1: + self.savedresponse = " " + str(int(seconds / (60 * 60 * 24))) + " days ago" + elif seconds / (60 * 60) > 1: + self.savedresponse = " " + str(int(seconds / (60 * 60))) + " hours ago" + elif seconds / (60) > 1: + self.savedresponse = " " + str(int(seconds / 60)) + " minutes ago" + else: + self.savedresponse = "Just a moment ago!" + return self.savedresponse diff --git a/forum/routes.py b/forum/routes.py index dc14a14..5e593e7 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,7 +3,7 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .models import User, Post, Subforum, valid_content, valid_title, db, generateLinkPath, error from .user import username_taken, email_taken, valid_username # Route handlers for login, browsing, and content creation. @@ -102,8 +102,8 @@ def viewpost(): if not post: return error("That post does not exist!") subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) - # Newest comments appear first for easier reading. - comments = Comment.query.filter(Comment.post_id == postid).order_by(Comment.id.desc()) + # Newest replies appear first for easier reading. + comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) @login_required @@ -116,9 +116,9 @@ def comment(): return error("That post does not exist!") content = request.form['content'] postdate = datetime.datetime.now() - comment = Comment(content, postdate) - current_user.comments.append(comment) - post.comments.append(comment) + reply = Post(content, postdate) + reply.parent_id = post_id + current_user.posts.append(reply) db.session.commit() return redirect("/viewpost?post=" + str(post_id)) @@ -145,7 +145,7 @@ def action_post(): retry = True if retry: return render_template("createpost.html", subforum=subforum, errors=errors) - post = Post(title, content, datetime.datetime.now()) + post = Post(content, datetime.datetime.now(), title=title) subforum.posts.append(post) user.posts.append(post) db.session.commit() From da74784bb87831fe2aae6c66ca15069a8f6ad5c0 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Apr 2026 14:11:46 -0400 Subject: [PATCH 14/68] worked on subform as own file --- forum/Subform.py | 29 +++++++++++++++++++++++-- forum/app.py | 3 ++- forum/models.py | 56 +++++++++++++++++++++++++----------------------- forum/routes.py | 3 ++- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/forum/Subform.py b/forum/Subform.py index f250ebf..a7870ff 100644 --- a/forum/Subform.py +++ b/forum/Subform.py @@ -1,7 +1,7 @@ # Shared SQLAlchemy object used by the app factory and all models. from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() +from .models import db class Subforum(db.Model): # Represent a forum category and its optional child subforums. @@ -17,4 +17,29 @@ class Subforum(db.Model): def __init__(self, title, description): self.title = title - self.description = description \ No newline at end of file + self.description = description + +def error(errormessage): + return "" + errormessage + "" + +def generateLinkPath(subforumid): + links = [] + subforum = Subforum.query.filter(Subforum.id == subforumid).first() + parent = Subforum.query.filter(Subforum.id == subforum.parent_id).first() + links.append("" + subforum.title + "") + while parent is not None: + links.append("" + parent.title + "") + parent = Subforum.query.filter(Subforum.id == parent.parent_id).first() + links.append("Forum Index") + link = "" + for l in reversed(links): + link = link + " / " + l + return link + + +# Post validation helpers +def valid_title(title): + return len(title) > 4 and len(title) < 140 + +def valid_content(content): + return len(content) > 10 and len(content) < 5000 \ No newline at end of file diff --git a/forum/app.py b/forum/app.py index 8a6c32a..72bf263 100644 --- a/forum/app.py +++ b/forum/app.py @@ -1,7 +1,8 @@ from flask import render_template from flask_login import LoginManager -from .models import Subforum, db, User +from .models import db, User +from .Subform import Subforum, db from forum import create_app # Build the Flask app using the package factory. diff --git a/forum/models.py b/forum/models.py index 6766044..fca14ff 100644 --- a/forum/models.py +++ b/forum/models.py @@ -8,6 +8,8 @@ db = SQLAlchemy() +from .Subform import Subforum, generateLinkPath + # Database models class User(UserMixin, db.Model): # Store account information and ownership of posts/comments. @@ -72,20 +74,20 @@ def get_time_string(self): return self.savedresponce -class Subforum(db.Model): - # Represent a forum category and its optional child subforums. - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text, unique=True) - description = db.Column(db.Text) - subforums = db.relationship("Subforum") - parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - posts = db.relationship("Post", backref="subforum") - path = None - hidden = db.Column(db.Boolean, default=False) - - def __init__(self, title, description): - self.title = title - self.description = description +# class Subforum(db.Model): +# # Represent a forum category and its optional child subforums. +# id = db.Column(db.Integer, primary_key=True) +# title = db.Column(db.Text, unique=True) +# description = db.Column(db.Text) +# subforums = db.relationship("Subforum") +# parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) +# posts = db.relationship("Post", backref="subforum") +# path = None +# hidden = db.Column(db.Boolean, default=False) + +# def __init__(self, title, description): +# self.title = title +# self.description = description class Comment(db.Model): # Store a comment attached to a post and authored by a user. @@ -127,19 +129,19 @@ def get_time_string(self): def error(errormessage): return "" + errormessage + "" -def generateLinkPath(subforumid): - links = [] - subforum = Subforum.query.filter(Subforum.id == subforumid).first() - parent = Subforum.query.filter(Subforum.id == subforum.parent_id).first() - links.append("" + subforum.title + "") - while parent is not None: - links.append("" + parent.title + "") - parent = Subforum.query.filter(Subforum.id == parent.parent_id).first() - links.append("Forum Index") - link = "" - for l in reversed(links): - link = link + " / " + l - return link +# def generateLinkPath(subforumid): +# links = [] +# subforum = Subforum.query.filter(Subforum.id == subforumid).first() +# parent = Subforum.query.filter(Subforum.id == subforum.parent_id).first() +# links.append("" + subforum.title + "") +# while parent is not None: +# links.append("" + parent.title + "") +# parent = Subforum.query.filter(Subforum.id == parent.parent_id).first() +# links.append("Forum Index") +# link = "" +# for l in reversed(links): +# link = link + " / " + l +# return link # Post validation helpers diff --git a/forum/routes.py b/forum/routes.py index dc14a14..dfe42a9 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,8 +3,9 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .models import User, Post, Comment, valid_content, valid_title, db, error, generateLinkPath, Subforum from .user import username_taken, email_taken, valid_username +#from .Subform import Subforum, db, generateLinkPath # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. From 7381cce27212699ea7e68a8a6c1893164c7cbc2c Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 14:29:50 -0400 Subject: [PATCH 15/68] __init__ order swapped --- forum/post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum/post.py b/forum/post.py index b305fab..c883b57 100644 --- a/forum/post.py +++ b/forum/post.py @@ -17,7 +17,7 @@ class Post(db.Model): lastcheck = None savedresponse = None - def __init__(self, content, postdate, title=None): + def __init__(self, title=None, content=None, postdate=None): self.title = title self.content = content self.postdate = postdate From 37e05ca8a478b5e7c956877ae1a4a83d6fe96724 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Apr 2026 14:30:55 -0400 Subject: [PATCH 16/68] relationship to users in class subforum --- forum/Subform.py | 1 + 1 file changed, 1 insertion(+) diff --git a/forum/Subform.py b/forum/Subform.py index a7870ff..8447e2b 100644 --- a/forum/Subform.py +++ b/forum/Subform.py @@ -14,6 +14,7 @@ class Subforum(db.Model): path = None hidden = db.Column(db.Boolean, default=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + users = db.relationship("User", backref="subforum") def __init__(self, title, description): self.title = title From 0964f4d356f8be06140c84bb0e89198371080286 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Apr 2026 14:47:10 -0400 Subject: [PATCH 17/68] Allows admins to create subforums --- forum/routes.py | 54 +++++++++++++++++++++++++++++ forum/templates/createsubforum.html | 31 +++++++++++++++++ forum/templates/subforum.html | 3 ++ forum/templates/subforums.html | 6 ++++ 4 files changed, 94 insertions(+) create mode 100644 forum/templates/createsubforum.html diff --git a/forum/routes.py b/forum/routes.py index dfe42a9..c82ada7 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -152,3 +152,57 @@ def action_post(): db.session.commit() return redirect("/viewpost?post=" + str(post.id)) +@login_required +@rt.route('/createsubforum', methods=['GET', 'POST']) +def create_subforum_page(): + # Only admins can create subforums. + if not current_user.admin: + return error("Only administrators can create subforums!") + + # Get the parent subforum if specified + parent_id = request.args.get('parent') or request.form.get('parent_id') + parent_subforum = None + if parent_id: + try: + parent_id = int(parent_id) + parent_subforum = Subforum.query.get(parent_id) + except (ValueError, TypeError): + pass + + if request.method == 'POST': + title = request.form['title'] + description = request.form['description'] + + # Validate input + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(description): + errors.append("Description must be between 10 and 5000 characters long!") + retry = True + + if retry: + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) + + # Create the new subforum + subforum = Subforum(title, description) + + # Add to parent if specified + if parent_subforum: + parent_subforum.subforums.append(subforum) + + db.session.add(subforum) + db.session.commit() + + if parent_subforum: + return redirect("/subforum?sub=" + str(parent_subforum.id)) + else: + return redirect("/") + + # GET request - show the form + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) + diff --git a/forum/templates/createsubforum.html b/forum/templates/createsubforum.html new file mode 100644 index 0000000..0635b74 --- /dev/null +++ b/forum/templates/createsubforum.html @@ -0,0 +1,31 @@ +{% extends 'layout.html' %} +{% block body %} + +
+

{% if parent_subforum %}Create a Subforum in {{ parent_subforum.title }}{% else %}Create a New Forum{% endif %}

+ +
+ +
+

+ +
+

+ + {% if not parent_subforum %} +
+

+ {% else %} + + {% endif %} + + +
+
+ +{% endblock %} diff --git a/forum/templates/subforum.html b/forum/templates/subforum.html index 8783cd1..a69e721 100644 --- a/forum/templates/subforum.html +++ b/forum/templates/subforum.html @@ -28,6 +28,9 @@ {% endfor %} + + {% endblock %} From a68f9da194355e031e1662bfe3da2f67b37a959e Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 16:09:42 -0400 Subject: [PATCH 18/68] Debugging why it will not run --- forum/post-routes.py | 74 ++++++++++++++++++++++++ forum/routes.py | 134 +++++++++++++++++++++---------------------- run.sh | 2 +- 3 files changed, 142 insertions(+), 68 deletions(-) create mode 100644 forum/post-routes.py diff --git a/forum/post-routes.py b/forum/post-routes.py new file mode 100644 index 0000000..b22d13c --- /dev/null +++ b/forum/post-routes.py @@ -0,0 +1,74 @@ +from flask import render_template, request, redirect, url_for +from flask_login import current_user, login_user, logout_user +from flask_login.utils import login_required +import datetime +from flask import Blueprint, render_template, request, redirect, url_for +from .models import User, Post, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .user import username_taken, email_taken, valid_username +post_rt = Blueprint('post_routes', __name__, template_folder='templates') + +@post_rt.route('/addpost') +@login_required +def addpost(): + # Show the new post form for the selected subforum. + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + return render_template("createpost.html", subforum=subforum) + +@post_rt.route('/viewpost') +def viewpost(): + # Show one post and its comments. + postid = int(request.args.get("post")) + post = Post.query.filter(Post.id == postid).first() + if not post: + return error("That post does not exist!") + subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) + # Newest replies appear first for easier reading. + comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) + return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + +@post_rt.route('/action_comment', methods=['POST', 'GET']) +@login_required +def comment(): + # Create a comment, attach it to the user and post, then save it. + post_id = int(request.args.get("post")) + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + content = request.form['content'] + postdate = datetime.datetime.now() + reply = Post(content, postdate) + reply.parent_id = post_id + current_user.posts.append(reply) + db.session.commit() + return redirect("/viewpost?post=" + str(post_id)) + +@post_rt.route('/action_post', methods=['POST']) +@login_required +def action_post(): + # Validate a new post before saving it. + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return redirect(url_for("index")) + + user = current_user + title = request.form['title'] + content = request.form['content'] + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(content): + errors.append("Post must be between 10 and 5000 characters long!") + retry = True + if retry: + return render_template("createpost.html", subforum=subforum, errors=errors) + post = Post(content, datetime.datetime.now(), title=title) + subforum.posts.append(post) + user.posts.append(post) + db.session.commit() + return redirect("/viewpost?post=" + str(post.id)) diff --git a/forum/routes.py b/forum/routes.py index 5e593e7..c5a3cff 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -83,71 +83,71 @@ def loginform(): return render_template("login.html") -@login_required -@rt.route('/addpost') -def addpost(): - # Show the new post form for the selected subforum. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return error("That subforum does not exist!") - - return render_template("createpost.html", subforum=subforum) - -@rt.route('/viewpost') -def viewpost(): - # Show one post and its comments. - postid = int(request.args.get("post")) - post = Post.query.filter(Post.id == postid).first() - if not post: - return error("That post does not exist!") - subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) - # Newest replies appear first for easier reading. - comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) - return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) - -@login_required -@rt.route('/action_comment', methods=['POST', 'GET']) -def comment(): - # Create a comment, attach it to the user and post, then save it. - post_id = int(request.args.get("post")) - post = Post.query.filter(Post.id == post_id).first() - if not post: - return error("That post does not exist!") - content = request.form['content'] - postdate = datetime.datetime.now() - reply = Post(content, postdate) - reply.parent_id = post_id - current_user.posts.append(reply) - db.session.commit() - return redirect("/viewpost?post=" + str(post_id)) - -@login_required -@rt.route('/action_post', methods=['POST']) -def action_post(): - # Validate a new post before saving it. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return redirect(url_for("index")) - - user = current_user - title = request.form['title'] - content = request.form['content'] - # Collect validation errors first so the form can be shown again if needed. - errors = [] - retry = False - if not valid_title(title): - errors.append("Title must be between 4 and 140 characters long!") - retry = True - if not valid_content(content): - errors.append("Post must be between 10 and 5000 characters long!") - retry = True - if retry: - return render_template("createpost.html", subforum=subforum, errors=errors) - post = Post(content, datetime.datetime.now(), title=title) - subforum.posts.append(post) - user.posts.append(post) - db.session.commit() - return redirect("/viewpost?post=" + str(post.id)) +# @login_required +# @rt.route('/addpost') +# def addpost(): +# # Show the new post form for the selected subforum. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return error("That subforum does not exist!") + +# return render_template("createpost.html", subforum=subforum) + +# @rt.route('/viewpost') +# def viewpost(): +# # Show one post and its comments. +# postid = int(request.args.get("post")) +# post = Post.query.filter(Post.id == postid).first() +# if not post: +# return error("That post does not exist!") +# subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) +# # Newest replies appear first for easier reading. +# comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) +# return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + +# @login_required +# @rt.route('/action_comment', methods=['POST', 'GET']) +# def comment(): +# # Create a comment, attach it to the user and post, then save it. +# post_id = int(request.args.get("post")) +# post = Post.query.filter(Post.id == post_id).first() +# if not post: +# return error("That post does not exist!") +# content = request.form['content'] +# postdate = datetime.datetime.now() +# reply = Post(content, postdate) +# reply.parent_id = post_id +# current_user.posts.append(reply) +# db.session.commit() +# return redirect("/viewpost?post=" + str(post_id)) + +# @login_required +# @rt.route('/action_post', methods=['POST']) +# def action_post(): +# # Validate a new post before saving it. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return redirect(url_for("index")) + +# user = current_user +# title = request.form['title'] +# content = request.form['content'] +# # Collect validation errors first so the form can be shown again if needed. +# errors = [] +# retry = False +# if not valid_title(title): +# errors.append("Title must be between 4 and 140 characters long!") +# retry = True +# if not valid_content(content): +# errors.append("Post must be between 10 and 5000 characters long!") +# retry = True +# if retry: +# return render_template("createpost.html", subforum=subforum, errors=errors) +# post = Post(content, datetime.datetime.now(), title=title) +# subforum.posts.append(post) +# user.posts.append(post) +# db.session.commit() +# return redirect("/viewpost?post=" + str(post.id)) diff --git a/run.sh b/run.sh index a39697a..7ab7e41 100644 --- a/run.sh +++ b/run.sh @@ -3,4 +3,4 @@ export SECRET_KEY="kristofer" # honcho start # you can ALSO or RATHER use the following command to run the app -cd ./forum; flask run +cd ./forum && flask run --port=8000 From 73537e54e63c7d32fbc0723adbb1521802163448 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 16:10:45 -0400 Subject: [PATCH 19/68] init file is back --- forum/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 forum/__init__.py diff --git a/forum/__init__.py b/forum/__init__.py new file mode 100644 index 0000000..351c522 --- /dev/null +++ b/forum/__init__.py @@ -0,0 +1,18 @@ +from flask import Flask +from .routes import rt + +def create_app(): + """Construct the core application.""" + app = Flask(__name__, instance_relative_config=False) + app.config.from_object('config.Config') + # Register the main routes blueprint. + app.register_blueprint(rt) + # Set globals + from .models import db + db.init_app(app) + + with app.app_context(): + # Create tables on startup so the app can run against a fresh database. + db.create_all() + return app + From 6cd0081e60fa59e022cc8256d48abedc8ce430d1 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 8 Apr 2026 16:29:15 -0400 Subject: [PATCH 20/68] can post to forum and view --- forum/__init__.py | 1 + forum/{post-routes.py => post_routes.py} | 13 +++++++------ run.sh | 0 3 files changed, 8 insertions(+), 6 deletions(-) rename forum/{post-routes.py => post_routes.py} (89%) mode change 100644 => 100755 run.sh diff --git a/forum/__init__.py b/forum/__init__.py index 351c522..ff3e589 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,5 +1,6 @@ from flask import Flask from .routes import rt +from .post_routes import rt def create_app(): """Construct the core application.""" diff --git a/forum/post-routes.py b/forum/post_routes.py similarity index 89% rename from forum/post-routes.py rename to forum/post_routes.py index b22d13c..65b50d7 100644 --- a/forum/post-routes.py +++ b/forum/post_routes.py @@ -5,9 +5,10 @@ from flask import Blueprint, render_template, request, redirect, url_for from .models import User, Post, Subforum, valid_content, valid_title, db, generateLinkPath, error from .user import username_taken, email_taken, valid_username -post_rt = Blueprint('post_routes', __name__, template_folder='templates') +from .routes import rt -@post_rt.route('/addpost') + +@rt.route('/addpost') @login_required def addpost(): # Show the new post form for the selected subforum. @@ -17,7 +18,7 @@ def addpost(): return error("That subforum does not exist!") return render_template("createpost.html", subforum=subforum) -@post_rt.route('/viewpost') +@rt.route('/viewpost') def viewpost(): # Show one post and its comments. postid = int(request.args.get("post")) @@ -29,7 +30,7 @@ def viewpost(): comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) -@post_rt.route('/action_comment', methods=['POST', 'GET']) +@rt.route('/action_comment', methods=['POST', 'GET']) @login_required def comment(): # Create a comment, attach it to the user and post, then save it. @@ -45,7 +46,7 @@ def comment(): db.session.commit() return redirect("/viewpost?post=" + str(post_id)) -@post_rt.route('/action_post', methods=['POST']) +@rt.route('/action_post', methods=['POST']) @login_required def action_post(): # Validate a new post before saving it. @@ -67,7 +68,7 @@ def action_post(): retry = True if retry: return render_template("createpost.html", subforum=subforum, errors=errors) - post = Post(content, datetime.datetime.now(), title=title) + post = Post(title, content, datetime.datetime.now()) subforum.posts.append(post) user.posts.append(post) db.session.commit() diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 From 1a868a30e55fec195bc5f4fa0954a6de30cb60f1 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Apr 2026 16:35:57 -0400 Subject: [PATCH 21/68] fixed Subform.py to subforum.py --- forum/__init__.py | 2 + forum/app.py | 2 +- forum/models.py | 2 +- forum/routes.py | 111 +++++++++++++++--------------- forum/{Subform.py => subforum.py} | 0 forum/subforum_route.py | 79 +++++++++++++++++++++ 6 files changed, 139 insertions(+), 57 deletions(-) rename forum/{Subform.py => subforum.py} (100%) create mode 100644 forum/subforum_route.py diff --git a/forum/__init__.py b/forum/__init__.py index 351c522..b566e54 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,5 +1,6 @@ from flask import Flask from .routes import rt +from .subforum_route import rt def create_app(): """Construct the core application.""" @@ -9,6 +10,7 @@ def create_app(): app.register_blueprint(rt) # Set globals from .models import db + from .subforum import db db.init_app(app) with app.app_context(): diff --git a/forum/app.py b/forum/app.py index 72bf263..d01ba59 100644 --- a/forum/app.py +++ b/forum/app.py @@ -2,7 +2,7 @@ from flask import render_template from flask_login import LoginManager from .models import db, User -from .Subform import Subforum, db +from .subforum import Subforum, db from forum import create_app # Build the Flask app using the package factory. diff --git a/forum/models.py b/forum/models.py index fca14ff..f64fc8c 100644 --- a/forum/models.py +++ b/forum/models.py @@ -8,7 +8,7 @@ db = SQLAlchemy() -from .Subform import Subforum, generateLinkPath +from .subforum import Subforum, generateLinkPath # Database models class User(UserMixin, db.Model): diff --git a/forum/routes.py b/forum/routes.py index c82ada7..a0124b4 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -7,6 +7,7 @@ from .user import username_taken, email_taken, valid_username #from .Subform import Subforum, db, generateLinkPath + # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. @@ -65,18 +66,18 @@ def action_createaccount(): return redirect("/") -@rt.route('/subforum') -def subforum(): - # Show one subforum, its posts, and its child subforums. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return error("That subforum does not exist!") - posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) - subforumpath = subforum.path or generateLinkPath(subforum.id) +# @rt.route('/subforum') +# def subforum(): +# # Show one subforum, its posts, and its child subforums. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return error("That subforum does not exist!") +# posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) +# subforumpath = subforum.path or generateLinkPath(subforum.id) - subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() - return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) +# subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() +# return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) @rt.route('/loginform') def loginform(): @@ -152,57 +153,57 @@ def action_post(): db.session.commit() return redirect("/viewpost?post=" + str(post.id)) -@login_required -@rt.route('/createsubforum', methods=['GET', 'POST']) -def create_subforum_page(): - # Only admins can create subforums. - if not current_user.admin: - return error("Only administrators can create subforums!") +# @login_required +# @rt.route('/createsubforum', methods=['GET', 'POST']) +# def create_subforum_page(): +# # Only admins can create subforums. +# if not current_user.admin: +# return error("Only administrators can create subforums!") - # Get the parent subforum if specified - parent_id = request.args.get('parent') or request.form.get('parent_id') - parent_subforum = None - if parent_id: - try: - parent_id = int(parent_id) - parent_subforum = Subforum.query.get(parent_id) - except (ValueError, TypeError): - pass +# # Get the parent subforum if specified +# parent_id = request.args.get('parent') or request.form.get('parent_id') +# parent_subforum = None +# if parent_id: +# try: +# parent_id = int(parent_id) +# parent_subforum = Subforum.query.get(parent_id) +# except (ValueError, TypeError): +# pass - if request.method == 'POST': - title = request.form['title'] - description = request.form['description'] +# if request.method == 'POST': +# title = request.form['title'] +# description = request.form['description'] - # Validate input - errors = [] - retry = False - if not valid_title(title): - errors.append("Title must be between 4 and 140 characters long!") - retry = True - if not valid_content(description): - errors.append("Description must be between 10 and 5000 characters long!") - retry = True +# # Validate input +# errors = [] +# retry = False +# if not valid_title(title): +# errors.append("Title must be between 4 and 140 characters long!") +# retry = True +# if not valid_content(description): +# errors.append("Description must be between 10 and 5000 characters long!") +# retry = True - if retry: - subforums = Subforum.query.filter(Subforum.parent_id == None).all() - return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) +# if retry: +# subforums = Subforum.query.filter(Subforum.parent_id == None).all() +# return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) - # Create the new subforum - subforum = Subforum(title, description) +# # Create the new subforum +# subforum = Subforum(title, description) - # Add to parent if specified - if parent_subforum: - parent_subforum.subforums.append(subforum) +# # Add to parent if specified +# if parent_subforum: +# parent_subforum.subforums.append(subforum) - db.session.add(subforum) - db.session.commit() +# db.session.add(subforum) +# db.session.commit() - if parent_subforum: - return redirect("/subforum?sub=" + str(parent_subforum.id)) - else: - return redirect("/") +# if parent_subforum: +# return redirect("/subforum?sub=" + str(parent_subforum.id)) +# else: +# return redirect("/") - # GET request - show the form - subforums = Subforum.query.filter(Subforum.parent_id == None).all() - return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) +# # GET request - show the form +# subforums = Subforum.query.filter(Subforum.parent_id == None).all() +# return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) diff --git a/forum/Subform.py b/forum/subforum.py similarity index 100% rename from forum/Subform.py rename to forum/subforum.py diff --git a/forum/subforum_route.py b/forum/subforum_route.py new file mode 100644 index 0000000..2775822 --- /dev/null +++ b/forum/subforum_route.py @@ -0,0 +1,79 @@ +from flask import render_template, request, redirect, url_for +from flask_login import current_user, login_user, logout_user +from flask_login.utils import login_required +import datetime +from flask import Blueprint, render_template, request, redirect, url_for +from .models import User, Post, Comment, valid_content, valid_title, db, error, generateLinkPath, Subforum + +# Route handlers for login, browsing, and content creation. +# The app is small enough to keep in one blueprint for now. + +from .routes import rt + +@rt.route('/subforum') +def subforum(): + # Show one subforum, its posts, and its child subforums. + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) + subforumpath = subforum.path or generateLinkPath(subforum.id) + + subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() + return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) + +#@login_required +@rt.route('/createsubforum', methods=['GET', 'POST']) +def create_subforum_page(): + # Only admins can create subforums. + if not current_user.admin: + return error("Only administrators can create subforums!") + + # Get the parent subforum if specified + parent_id = request.args.get('parent') or request.form.get('parent_id') + parent_subforum = None + if parent_id: + try: + parent_id = int(parent_id) + parent_subforum = Subforum.query.get(parent_id) + except (ValueError, TypeError): + pass + + if request.method == 'POST': + title = request.form['title'] + description = request.form['description'] + + # Validate input + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(description): + errors.append("Description must be between 10 and 5000 characters long!") + retry = True + + if retry: + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) + + # Create the new subforum + subforum = Subforum(title, description) + + # Add to parent if specified + if parent_subforum: + parent_subforum.subforums.append(subforum) + + db.session.add(subforum) + db.session.commit() + + if parent_subforum: + return redirect("/subforum?sub=" + str(parent_subforum.id)) + else: + return redirect("/") + + # GET request - show the form + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) + From f91cffe67ee63e5f2f84b23213795ffff62e4c25 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Apr 2026 17:05:31 -0400 Subject: [PATCH 22/68] admin user ability to delete subforum if not one of the initial ones --- forum/app.py | 13 +++++++------ forum/subforum.py | 1 + forum/subforum_route.py | 31 +++++++++++++++++++++++++++++++ forum/templates/subforum.html | 8 ++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/forum/app.py b/forum/app.py index d01ba59..93bfce6 100644 --- a/forum/app.py +++ b/forum/app.py @@ -16,15 +16,16 @@ def init_site(): # Create the default forum structure on first run. print("creating initial subforums") - admin = add_subforum("Forum", "Announcements, bug reports, and general discussion about the forum belongs here") - add_subforum("Announcements", "View forum announcements here",admin) - add_subforum("Bug Reports", "Report bugs with the forum here", admin) - add_subforum("General Discussion", "Use this subforum to post anything you want") - add_subforum("Other", "Discuss other things here") + admin = add_subforum("Forum", "Announcements, bug reports, and general discussion about the forum belongs here", protected=True) + add_subforum("Announcements", "View forum announcements here", admin, protected=True) + add_subforum("Bug Reports", "Report bugs with the forum here", admin, protected=True) + add_subforum("General Discussion", "Use this subforum to post anything you want", protected=True) + add_subforum("Other", "Discuss other things here", protected=True) -def add_subforum(title, description, parent=None): +def add_subforum(title, description, parent=None, protected=False): # Avoid duplicate subforums at the same level. sub = Subforum(title, description) + sub.protected = protected if parent: for subforum in parent.subforums: if subforum.title == title: diff --git a/forum/subforum.py b/forum/subforum.py index 8447e2b..afb31de 100644 --- a/forum/subforum.py +++ b/forum/subforum.py @@ -13,6 +13,7 @@ class Subforum(db.Model): posts = db.relationship("Post", backref="subforum") path = None hidden = db.Column(db.Boolean, default=False) + protected = db.Column(db.Boolean, default=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) users = db.relationship("User", backref="subforum") diff --git a/forum/subforum_route.py b/forum/subforum_route.py index 2775822..fb7eded 100644 --- a/forum/subforum_route.py +++ b/forum/subforum_route.py @@ -77,3 +77,34 @@ def create_subforum_page(): subforums = Subforum.query.filter(Subforum.parent_id == None).all() return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) +@login_required +@rt.route('/deletesubforum', methods=['POST']) +def delete_subforum(): + # Only admins can delete subforums. + if not current_user.admin: + return error("Only administrators can delete subforums!") + + subforum_id = int(request.form.get('subforum_id')) + subforum = Subforum.query.get(subforum_id) + + if not subforum: + return error("That subforum does not exist!") + + # Prevent deletion of protected subforums + if subforum.protected: + return error("This subforum is protected and cannot be deleted!") + + # Prevent deletion if subforum has child subforums or posts + if subforum.subforums or subforum.posts: + return error("Cannot delete a subforum that contains child subforums or posts!") + + parent_id = subforum.parent_id + db.session.delete(subforum) + db.session.commit() + + # Redirect back to parent or home + if parent_id: + return redirect("/subforum?sub=" + str(parent_id)) + else: + return redirect("/") + diff --git a/forum/templates/subforum.html b/forum/templates/subforum.html index a69e721..9e8796e 100644 --- a/forum/templates/subforum.html +++ b/forum/templates/subforum.html @@ -30,8 +30,16 @@ {% if current_user.is_authenticated %} {% if current_user.admin %} Create a subforum in {{ subforum.title }} | + Create a post in {{ subforum.title }} + {% if not subforum.protected %} + |
+ + +
{% endif %} + {% else %} Create a post in {{ subforum.title }} + {% endif %} {% else %} Log in or create an account to make a post in {{ subforum.title }} {% endif %} From 365e49c7b0f1e23cc5f8fcd086c532a96158c309 Mon Sep 17 00:00:00 2001 From: emoyer Date: Thu, 9 Apr 2026 13:35:42 -0400 Subject: [PATCH 23/68] Migrated MySQL --- MYSQL_MIGRATION_GUIDE.md | 154 +++++++++++++++++++++++ README.md | 10 ++ config.py | 256 ++++++++++++++++++++++++++++++++++++++- forum/__init__.py | 13 ++ forum/app.py | 2 +- forum/models.py | 12 +- requirements.txt | 4 +- run.sh | 33 ++++- 8 files changed, 466 insertions(+), 18 deletions(-) create mode 100644 MYSQL_MIGRATION_GUIDE.md create mode 100644 forum/__init__.py diff --git a/MYSQL_MIGRATION_GUIDE.md b/MYSQL_MIGRATION_GUIDE.md new file mode 100644 index 0000000..92e2899 --- /dev/null +++ b/MYSQL_MIGRATION_GUIDE.md @@ -0,0 +1,154 @@ +# MySQL Migration and Startup Guide + +This guide explains what we changed to move the app to MySQL, why the changes were needed, and how the app starts now. It is written for a team handoff, so it gives a quick summary first and then the deeper details. + +## Quick Overview + +- The app used to depend on SQLite-style assumptions. +- We moved the database config to MySQL and added PyMySQL support. +- We changed the launcher so the app starts with sensible defaults instead of stopping on missing environment variables. +- We fixed the schema so MySQL can create the tables cleanly. +- We added startup checks so the app can create the database and app user when MySQL access is available. + +In short: the app now starts, connects to MySQL, creates its tables, and seeds the default forum structure without the user having to understand the setup details first. + +## What We Changed + +### 1. Database configuration moved to MySQL + +The core database settings live in `config.py`. That file now builds a MySQL connection string using: + +- `DB_USER` +- `DB_PASSWORD` +- `DB_HOST` +- `DB_PORT` +- `DB_NAME` + +If those environment variables are not provided, the app uses defaults that match the project setup: + +- user: `zipchat_app` +- password: `password` +- host: `127.0.0.1` +- port: `3306` +- database: `ZipChat` + +This means a teammate can clone the repo and run the launcher without having to edit config files first. + +### 2. The launcher no longer hard-stops on missing password input + +`run.sh` used to exit when `DB_PASSWORD` was missing. That made startup brittle because the app could not even get to its own error handling. + +Now `run.sh`: + +- creates the virtual environment if needed +- installs requirements +- sets MySQL environment defaults +- provides a default DB password if one is not exported +- starts Flask + +That makes the startup path more forgiving for new users and for team members running the app locally. + +### 3. The schema was adjusted for MySQL rules + +MySQL does not allow unique indexes on plain `TEXT` columns without a key length. That is why `db.create_all()` originally failed on the `User` table. + +We changed the model fields that need uniqueness or fixed sizes: + +- `User.username` became a bounded string +- `User.email` became a bounded string +- `User.password_hash` became a bounded string +- `Post.title` became a bounded string +- `Subforum.title` became a bounded string + +That was the key schema fix that allowed MySQL table creation to succeed. + +### 4. Startup now tries to prepare MySQL automatically + +`config.py` now includes startup helpers that: + +- try to connect to MySQL as an admin user on the local machine +- create the `ZipChat` database if it is missing +- create the `zipchat_app` user if it is missing +- grant that user access to the database +- verify that the app credentials can connect + +This is a best-effort bootstrap. If MySQL admin access is unavailable on a specific machine, the code prints clear instructions instead of failing silently. + +### 5. MySQL driver support was added + +The app uses PyMySQL to talk to MySQL, and MySQL 9 can use `caching_sha2_password` authentication. That means the Python environment also needs the `cryptography` package. + +Without `cryptography`, the connection can fail during authentication even if the database settings are otherwise correct. + +## How Startup Works Now + +The current startup sequence is: + +1. `bash ./run.sh` is executed. +2. The script creates or reuses `.venv`. +3. Dependencies are installed. +4. Environment defaults are set for the app. +5. Flask starts with `forum.app`. +6. `forum/app.py` imports the Flask app factory. +7. `config.py` runs the MySQL setup checks. +8. MySQL admin access is attempted. +9. The database and app user are created if possible. +10. The app connects as `zipchat_app`. +11. `db.create_all()` creates tables. +12. The default subforums are seeded if needed. +13. The site starts listening on the configured port. + +That flow is why the app now feels more like a single command startup instead of a manual setup checklist. + +## Why Each Change Was Necessary + +### MySQL needed more explicit setup than SQLite + +SQLite is very forgiving because it stores data in a local file and usually does not require credentials. MySQL is stricter because it expects: + +- a running server +- a database name +- a user account +- a password or admin path +- permissions on the target database + +That is why a SQLite-style setup can work in development but fail once the backend is switched to MySQL. + +### The app needed a real default path + +Before the cleanup, startup depended on the user remembering environment variables and database details. That is brittle for a team workflow. + +The new defaults mean the repo itself now contains the expected values, and environment variables are only needed for overrides. + +### The models needed to match MySQL rules + +The ORM models are not just app code. They define the actual SQL table structure. If a field is too open-ended for a unique constraint, MySQL rejects the table creation. + +Changing `TEXT` to bounded `String` fields made the schema portable and allowed table creation to work consistently. + +## What A New Teammate Should Know + +- Start the app with `bash ./run.sh` from the project root. +- The app now carries sane defaults for the MySQL connection. +- The first startup may print MySQL setup warnings if local admin access is not available. +- If the database already exists, startup should proceed directly to table creation and app launch. +- If you want to override credentials, export the environment variables before running the launcher. + +## Important Files + +- `run.sh`: launch script and runtime defaults +- `config.py`: MySQL setup logic and SQLAlchemy configuration +- `forum/models.py`: database schema definitions +- `forum/app.py`: app startup, table creation, and seed data +- `requirements.txt`: Python dependencies, including PyMySQL and cryptography + +## Final Result + +The app now has a simpler onboarding story: + +- run one command +- get a MySQL-backed Flask app +- have the database schema created automatically +- use a predictable app user and database name + +That is the version of the project you can hand to a teammate without asking them to understand the whole migration history first. \ No newline at end of file diff --git a/README.md b/README.md index cf8b121..ecf1fe9 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,16 @@ This is a minimal forum written in python with Flask. It supports only the bare On first run, the default subforums will be created. Although custom subforums are not supported through any user interface, it is possible to modify forum/setup.py to create custom subforums. +## Team Guide + +If you want the full explanation of the MySQL migration, startup flow, and schema changes, read [MYSQL_MIGRATION_GUIDE.md](/Users/ethan/Projects/CircusCircus/MYSQL_MIGRATION_GUIDE.md). + +For normal local startup, run: + +```bash +bash ./run.sh +``` + ## Create a Github Organization - create an org diff --git a/config.py b/config.py index da78fea..b3df340 100644 --- a/config.py +++ b/config.py @@ -1,18 +1,264 @@ """ Flask configuration variables. +This file stores all the settings Flask needs to run the app, including +database connection details. It reads settings from environment variables, +but has safe defaults if those variables aren't set. """ from os import environ, path +import pymysql basedir = path.abspath(path.dirname(__file__)) # load_dotenv(path.join(basedir, '.env')) + +def try_admin_connection(): + """ + Try to connect to MySQL as root using multiple methods. + Returns a tuple of (connection, method) if successful, or (None, None) if all fail. + + This tries: + 1. Socket connection (Unix socket, no password needed on fresh installs) + 2. TCP/IP localhost with no password + 3. TCP/IP localhost with password from MYSQL_ROOT_PASSWORD env var + """ + # Common socket locations on macOS + socket_paths = [ + "/tmp/mysql.sock", + "/var/run/mysql/mysql.sock", + "/usr/local/var/run/mysql.sock" + ] + + # Try socket connections first (no password needed on fresh installs) + for socket_path in socket_paths: + try: + admin_conn = pymysql.connect( + user="root", + unix_socket=socket_path, + charset='utf8mb4' + ) + return (admin_conn, f"socket ({socket_path})") + except Exception: + continue + + # Try TCP/IP localhost with no password + try: + admin_conn = pymysql.connect( + host="127.0.0.1", + port=3306, + user="root", + password="", + charset='utf8mb4' + ) + return (admin_conn, "TCP/IP (no password)") + except Exception: + pass + + # Try TCP/IP with a password from environment variable (if user set one) + root_password = environ.get("MYSQL_ROOT_PASSWORD", "") + if root_password: + try: + admin_conn = pymysql.connect( + host="127.0.0.1", + port=3306, + user="root", + password=root_password, + charset='utf8mb4' + ) + return (admin_conn, "TCP/IP (with password)") + except Exception: + pass + + # All connection methods failed + return (None, None) + + +def setup_database_and_user(): + """ + Ensure the database and app user exist in MySQL. + This creates the database and app user during initial setup. + + Uses hardcoded 'root' for admin tasks and 'zipchat_app' for the app. + This keeps setup separate from app connection logic. + """ + # Setup always creates these - don't read from environment for these + app_user = "zipchat_app" + app_password = "password" + db_host = "127.0.0.1" + db_name = "ZipChat" + + # Try to connect as admin (root) + admin_conn, method = try_admin_connection() + + if admin_conn is None: + print(f"\nโš  Could not connect to MySQL as root") + print(f" Socket connection not available (fresh installs should have this)") + print(f" TCP/IP connection with no password didn't work either") + print(f"\n To fix:") + print(f" A) If you just installed MySQL, try: brew services restart mysql") + print(f" B) If you set a root password, provide it for setup:") + print(f" export MYSQL_ROOT_PASSWORD='your_root_password'") + print(f" bash ./run.sh") + print(f" C) Or manually create the database and user:") + print(f" mysql -u root -p") + print(f" CREATE DATABASE {db_name};") + print(f" CREATE USER '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';") + print(f" GRANT ALL PRIVILEGES ON {db_name}.* TO '{app_user}'@'{db_host}';") + print(f" FLUSH PRIVILEGES;") + print(f" EXIT;\n") + return False + + try: + print(f"โœ“ Connected to MySQL as root via {method}") + + with admin_conn.cursor() as cursor: + # Create database + cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;") + print(f" โœ“ Database '{db_name}' ready") + + # Create app user + try: + cursor.execute(f"CREATE USER IF NOT EXISTS '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';") + print(f" โœ“ App user '{app_user}' created") + except pymysql.err.OperationalError as e: + if "already exists" in str(e): + print(f" โœ“ App user '{app_user}' already exists") + else: + raise + + # Grant permissions + cursor.execute(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{app_user}'@'{db_host}';") + cursor.execute("FLUSH PRIVILEGES;") + print(f" โœ“ Permissions granted") + + admin_conn.commit() + admin_conn.close() + print(f"โœ“ MySQL setup complete\n") + return True + + except Exception as e: + print(f"โš  Error during setup: {e}\n") + admin_conn.close() + return False + + +def check_mysql_connection(): + """ + Verify the app can connect using the configured app credentials. + Shows helpful errors if something is wrong. + """ + # App uses these (can override with environment variables) + app_user = environ.get("DB_USER", "zipchat_app") + app_password = environ.get("DB_PASSWORD", "password") + db_host = environ.get("DB_HOST", "127.0.0.1") + db_port = int(environ.get("DB_PORT", "3306")) + db_name = environ.get("DB_NAME", "ZipChat") + + try: + connection = pymysql.connect( + host=db_host, + port=db_port, + user=app_user, + password=app_password, + database=db_name, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + connection.close() + print(f"โœ“ MySQL ready: {app_user}@{db_name}\n") + return True + + except pymysql.err.OperationalError as e: + error_code = e.args[0] if e.args else None + + if error_code == 2003: + print(f"โŒ MySQL not running on {db_host}:{db_port}") + print(f"Start it: brew services start mysql\n") + return False + + elif error_code == 1045: + print(f"โŒ Cannot login as '{app_user}'") + print(f"Either the user doesn't exist or the password is wrong.") + print(f"Make sure setup completed successfully above.\n") + return False + + elif error_code == 1049: + print(f"โŒ Database '{db_name}' doesn't exist") + print(f"Make sure setup completed successfully above.\n") + return False + + else: + print(f"โŒ MySQL error ({error_code}): {e}\n") + return False + + +def check_mysql_and_setup(): + """Initialize MySQL and check connection on app startup.""" + print("Checking MySQL setup...") + setup_database_and_user() + check_mysql_connection() + + class Config: - """Set Flask configuration from .env file.""" - # General Config + """ + Configuration class that stores all settings Flask needs. + This includes database connection info, secret keys, and SQLAlchemy options. + + Settings can be customized by setting environment variables: + - DB_USER: MySQL username (default: "zipchat_app") + - DB_PASSWORD: MySQL password (default: "password") + - DB_HOST: MySQL server address (default: "127.0.0.1") + - DB_PORT: MySQL server port (default: "3306") + - DB_NAME: Database name (default: "ZipChat") + """ + + # ========== GENERAL SETTINGS ========== + # This is a secret key used to encrypt session data and CSRF tokens. + # In production, this should be a long random string, not hardcoded! SECRET_KEY = 'kristofer' + + # Tell Flask which app module to use FLASK_APP = 'forum.app' - # Database - SQLALCHEMY_DATABASE_URI = 'sqlite:///circuscircus.db' + + # ========== DATABASE SETTINGS ========== + # All these settings can be overridden with environment variables. + # If not set, we use the defaults shown here. + + # Username for MySQL (the app will connect as this user) + DB_USER = environ.get("DB_USER", "zipchat_app") + + # Password for that MySQL user + # Default is "password" - change this or set via environment variable + DB_PASSWORD = environ.get("DB_PASSWORD", "password") + + # Where MySQL is running (localhost = 127.0.0.1 on your own computer) + DB_HOST = environ.get("DB_HOST", "127.0.0.1") + + # Port MySQL listens on (3306 is the standard MySQL port) + DB_PORT = environ.get("DB_PORT", "3306") + + # Name of the database to use + DB_NAME = environ.get("DB_NAME", "ZipChat") + + # Build the full database URL that SQLAlchemy uses to connect + # Format: mysql+pymysql://username:password@host:port/database + # This tells Python how to connect to the MySQL database + SQLALCHEMY_DATABASE_URI = ( + f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + + # SQLALCHEMY_ECHO prints SQL queries to the console (useful for debugging) + # Set to True if you want to see what SQL is being run SQLALCHEMY_ECHO = False - SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file + + # SQLALCHEMY_TRACK_MODIFICATIONS is usually set to False for better performance + # It tells SQLAlchemy not to track every single change to objects + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +# Run the MySQL setup check when this config file is imported +# This ensures MySQL is ready before the app tries to use it +try: + check_mysql_and_setup() +except Exception as e: + print(f"โš  Warning during MySQL setup: {e}") \ No newline at end of file diff --git a/forum/__init__.py b/forum/__init__.py new file mode 100644 index 0000000..037d254 --- /dev/null +++ b/forum/__init__.py @@ -0,0 +1,13 @@ +from flask import Flask +from forum.routes import rt +from forum.models import db + + +def create_app(): + """Construct the Flask application.""" + app = Flask(__name__, instance_relative_config=False) + app.config.from_object('config.Config') + app.register_blueprint(rt) + db.init_app(app) + return app + diff --git a/forum/app.py b/forum/app.py index 2b4747a..770a961 100644 --- a/forum/app.py +++ b/forum/app.py @@ -7,7 +7,7 @@ from flask import render_template from flask_login import LoginManager -from .models import Subforum, db, User +from forum.models import Subforum, db, User from forum import create_app # Build the Flask app using the package factory. diff --git a/forum/models.py b/forum/models.py index 6766044..1f0dff9 100644 --- a/forum/models.py +++ b/forum/models.py @@ -12,9 +12,9 @@ class User(UserMixin, db.Model): # Store account information and ownership of posts/comments. id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.Text, unique=True) - password_hash = db.Column(db.Text) - email = db.Column(db.Text, unique=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) admin = db.Column(db.Boolean, default=False) posts = db.relationship("Post", backref="user") comments = db.relationship("Comment", backref="user") @@ -32,7 +32,7 @@ def check_password(self, password): class Post(db.Model): # Store one forum post and link it to a user and subforum. id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text) + title = db.Column(db.String(140), nullable=False) content = db.Column(db.Text) comments = db.relationship("Comment", backref="post") user_id = db.Column(db.Integer, db.ForeignKey('user.id')) @@ -75,7 +75,7 @@ def get_time_string(self): class Subforum(db.Model): # Represent a forum category and its optional child subforums. id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text, unique=True) + title = db.Column(db.String(140), unique=True, nullable=False) description = db.Column(db.Text) subforums = db.relationship("Subforum") parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) @@ -90,7 +90,7 @@ def __init__(self, title, description): class Comment(db.Model): # Store a comment attached to a post and authored by a user. id = db.Column(db.Integer, primary_key=True) - content = db.Column(db.Text) + content = db.Column(db.Text, nullable=False) postdate = db.Column(db.DateTime) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) post_id = db.Column(db.Integer, db.ForeignKey("post.id")) diff --git a/requirements.txt b/requirements.txt index 39287bc..57494aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,6 @@ MarkupSafe SQLAlchemy Werkzeug wheel -honcho \ No newline at end of file +honcho +PyMySQL +cryptography \ No newline at end of file diff --git a/run.sh b/run.sh index a39697a..2e93126 100644 --- a/run.sh +++ b/run.sh @@ -1,6 +1,29 @@ -# export PORT=5006 -export SECRET_KEY="kristofer" -# honcho start +#!/usr/bin/env bash +set -e -# you can ALSO or RATHER use the following command to run the app -cd ./forum; flask run +# Always run from this script's directory. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Create virtual environment on first run. +if [ ! -d ".venv" ]; then + python3 -m venv .venv +fi + +. .venv/bin/activate +pip install -r requirements.txt >/dev/null + +export SECRET_KEY="${SECRET_KEY:-kristofer}" +export FLASK_APP=forum.app +export PORT="${PORT:-8000}" + +# MySQL-only configuration. +export DB_USER="${DB_USER:-zipchat_app}" +export DB_HOST="${DB_HOST:-127.0.0.1}" +export DB_PORT="${DB_PORT:-3306}" +export DB_NAME="${DB_NAME:-ZipChat}" +export DB_PASSWORD="${DB_PASSWORD:-password}" + +echo "Starting with MySQL database: $DB_NAME" + +flask run --port "$PORT" From 753b0c7234bc17672adeb96ed6cf29ba1cf160be Mon Sep 17 00:00:00 2001 From: emoyer Date: Thu, 9 Apr 2026 14:15:10 -0400 Subject: [PATCH 24/68] Added explaination of why and how MySQL setup works. --- config.py | 171 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 126 insertions(+), 45 deletions(-) diff --git a/config.py b/config.py index b3df340..7ba94ae 100644 --- a/config.py +++ b/config.py @@ -3,32 +3,49 @@ This file stores all the settings Flask needs to run the app, including database connection details. It reads settings from environment variables, but has safe defaults if those variables aren't set. + +This file also does a small amount of startup work for MySQL so the app can +try to prepare its database automatically before Flask builds tables. """ from os import environ, path import pymysql basedir = path.abspath(path.dirname(__file__)) +# If we ever want to load a local .env file again, this is the place to do it. +# It is left commented out because the project currently relies on environment +# variables provided by the launcher script instead of a separate .env file. +# from dotenv import load_dotenv # load_dotenv(path.join(basedir, '.env')) def try_admin_connection(): """ - Try to connect to MySQL as root using multiple methods. - Returns a tuple of (connection, method) if successful, or (None, None) if all fail. - - This tries: - 1. Socket connection (Unix socket, no password needed on fresh installs) - 2. TCP/IP localhost with no password - 3. TCP/IP localhost with password from MYSQL_ROOT_PASSWORD env var + Try to connect to MySQL as the local admin user. + + Why this exists: + - The app needs a way to create the database and app user on first run. + - On some machines MySQL allows local admin access through a socket. + - On other machines the root account may need a password. + + What it returns: + - (connection, method) if one of the connection attempts worked. + - (None, None) if every attempt failed. + + The function tries the most common local MySQL access paths first and only + falls back to a root password if the environment provides one. """ - # Common socket locations on macOS + # Common socket locations on macOS. + # A socket connection is the most convenient option when MySQL is installed + # locally because it may work without needing a password at all. socket_paths = [ "/tmp/mysql.sock", "/var/run/mysql/mysql.sock", "/usr/local/var/run/mysql.sock" ] - # Try socket connections first (no password needed on fresh installs) + # Try socket connections first. + # We stop at the first one that works because the exact socket path can + # vary depending on how MySQL was installed. for socket_path in socket_paths: try: admin_conn = pymysql.connect( @@ -38,9 +55,12 @@ def try_admin_connection(): ) return (admin_conn, f"socket ({socket_path})") except Exception: + # If this socket does not exist or the server rejects the connection, + # we just move on to the next possible path. continue - # Try TCP/IP localhost with no password + # Try plain TCP/IP next. + # This is the simplest network-style connection to local MySQL. try: admin_conn = pymysql.connect( host="127.0.0.1", @@ -53,7 +73,8 @@ def try_admin_connection(): except Exception: pass - # Try TCP/IP with a password from environment variable (if user set one) + # If the machine has a root password, allow the launcher to supply it. + # This keeps the code flexible for teams with different MySQL setups. root_password = environ.get("MYSQL_ROOT_PASSWORD", "") if root_password: try: @@ -66,30 +87,50 @@ def try_admin_connection(): ) return (admin_conn, "TCP/IP (with password)") except Exception: + # A password was provided, but it still did not work. + # The caller will handle the failure message. pass - # All connection methods failed + # If we get here, MySQL admin access is not available through any of the + # local methods we tried. return (None, None) def setup_database_and_user(): """ - Ensure the database and app user exist in MySQL. - This creates the database and app user during initial setup. - - Uses hardcoded 'root' for admin tasks and 'zipchat_app' for the app. - This keeps setup separate from app connection logic. + Ensure the database and app user exist in MySQL. + + What this does: + - creates the database if it does not exist + - creates the app user if it does not exist + - grants the app user access to the database + + Why it is here: + - A teammate should be able to run the app without manually preparing MySQL + every time. + - This is best-effort bootstrapping, not a replacement for proper database + administration in production. + + Important detail: + - This function uses a local MySQL admin connection only for setup tasks. + - The actual app still connects as the normal application user. """ - # Setup always creates these - don't read from environment for these + # These values define the default app database identity. + # We keep them simple and consistent so the launcher and config agree. app_user = "zipchat_app" app_password = "password" db_host = "127.0.0.1" db_name = "ZipChat" - # Try to connect as admin (root) + # Try to connect as the local MySQL admin user. + # If this fails, we can still continue later and let the normal app + # connection path explain what is missing. admin_conn, method = try_admin_connection() if admin_conn is None: + # We could not obtain admin access, so we cannot auto-create anything. + # The printed instructions are meant to tell a teammate exactly what to + # do next without having to inspect the code. print(f"\nโš  Could not connect to MySQL as root") print(f" Socket connection not available (fresh installs should have this)") print(f" TCP/IP connection with no password didn't work either") @@ -111,11 +152,15 @@ def setup_database_and_user(): print(f"โœ“ Connected to MySQL as root via {method}") with admin_conn.cursor() as cursor: - # Create database + # Create the database if it does not already exist. + # Backticks around the name make the SQL a little safer if the + # database name ever contains special characters. cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;") print(f" โœ“ Database '{db_name}' ready") - # Create app user + # Create the application user. + # This is the user the Flask app itself will use during normal + # runtime, not the root/admin account. try: cursor.execute(f"CREATE USER IF NOT EXISTS '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';") print(f" โœ“ App user '{app_user}' created") @@ -125,7 +170,9 @@ def setup_database_and_user(): else: raise - # Grant permissions + # Grant the app user permission to work only with this database. + # This keeps the account scoped to the project instead of giving it + # access to every database on the server. cursor.execute(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{app_user}'@'{db_host}';") cursor.execute("FLUSH PRIVILEGES;") print(f" โœ“ Permissions granted") @@ -136,6 +183,8 @@ def setup_database_and_user(): return True except Exception as e: + # If anything unexpected happens during setup, print the exception so a + # teammate can see what went wrong instead of getting a silent failure. print(f"โš  Error during setup: {e}\n") admin_conn.close() return False @@ -144,9 +193,14 @@ def setup_database_and_user(): def check_mysql_connection(): """ Verify the app can connect using the configured app credentials. - Shows helpful errors if something is wrong. + + This is the real runtime check for the Flask app. + If this succeeds, the database settings are good enough for normal use. + If it fails, we print a human-friendly explanation of the problem so the + user does not have to decode a long stack trace first. """ - # App uses these (can override with environment variables) + # Read the values the actual Flask app will use. + # These can still be overridden from the environment when needed. app_user = environ.get("DB_USER", "zipchat_app") app_password = environ.get("DB_PASSWORD", "password") db_host = environ.get("DB_HOST", "127.0.0.1") @@ -154,6 +208,8 @@ def check_mysql_connection(): db_name = environ.get("DB_NAME", "ZipChat") try: + # This is the same style of connection SQLAlchemy will use when it + # creates tables and runs ORM queries. connection = pymysql.connect( host=db_host, port=db_port, @@ -168,6 +224,8 @@ def check_mysql_connection(): return True except pymysql.err.OperationalError as e: + # PyMySQL returns MySQL error codes, so we can match common failures and + # print a specific fix for each one. error_code = e.args[0] if e.args else None if error_code == 2003: @@ -187,12 +245,23 @@ def check_mysql_connection(): return False else: + # If we do not recognize the error code, still show the raw error so + # the team has something concrete to debug. print(f"โŒ MySQL error ({error_code}): {e}\n") return False def check_mysql_and_setup(): - """Initialize MySQL and check connection on app startup.""" + """ + Run the MySQL bootstrap sequence during app startup. + + The order matters: + 1. Try to create the database and app user. + 2. Verify that the app can connect with the runtime credentials. + + This function is intentionally small because it is called when the config + module is imported, so it should be easy to read and easy to debug. + """ print("Checking MySQL setup...") setup_database_and_user() check_mysql_connection() @@ -209,55 +278,67 @@ class Config: - DB_HOST: MySQL server address (default: "127.0.0.1") - DB_PORT: MySQL server port (default: "3306") - DB_NAME: Database name (default: "ZipChat") + + The class is used by Flask-SQLAlchemy and the app factory, so keeping the + settings here makes startup predictable and easy to trace. """ # ========== GENERAL SETTINGS ========== - # This is a secret key used to encrypt session data and CSRF tokens. - # In production, this should be a long random string, not hardcoded! + # SECRET_KEY signs Flask sessions and CSRF tokens. + # In a real deployment this should come from the environment, not be hard- + # coded in source control. SECRET_KEY = 'kristofer' - # Tell Flask which app module to use + # Flask can use this to know which module contains the app entry point. FLASK_APP = 'forum.app' # ========== DATABASE SETTINGS ========== - # All these settings can be overridden with environment variables. - # If not set, we use the defaults shown here. + # These values describe the database connection the application will use. + # Each one can be overridden from the shell, which keeps local development + # simple while still allowing other environments to customize the values. - # Username for MySQL (the app will connect as this user) + # Username for the MySQL account the app uses at runtime. DB_USER = environ.get("DB_USER", "zipchat_app") - # Password for that MySQL user - # Default is "password" - change this or set via environment variable + # Password for the runtime MySQL account. + # The launcher defaults to "password" so a new clone can start without + # asking the user to configure secrets first. DB_PASSWORD = environ.get("DB_PASSWORD", "password") - # Where MySQL is running (localhost = 127.0.0.1 on your own computer) + # Hostname or IP address where MySQL is running. DB_HOST = environ.get("DB_HOST", "127.0.0.1") - # Port MySQL listens on (3306 is the standard MySQL port) + # TCP port MySQL listens on. DB_PORT = environ.get("DB_PORT", "3306") - # Name of the database to use + # Name of the database the app reads and writes to. DB_NAME = environ.get("DB_NAME", "ZipChat") - # Build the full database URL that SQLAlchemy uses to connect - # Format: mysql+pymysql://username:password@host:port/database - # This tells Python how to connect to the MySQL database + # SQLAlchemy wants a single database URL instead of separate pieces. + # The format below means: + # mysql+pymysql -> use MySQL with the PyMySQL driver + # username -> the runtime MySQL user + # password -> that user's password + # host:port -> where MySQL is listening + # database -> which schema to use SQLALCHEMY_DATABASE_URI = ( f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" ) - # SQLALCHEMY_ECHO prints SQL queries to the console (useful for debugging) - # Set to True if you want to see what SQL is being run + # When True, SQLAlchemy prints every SQL statement it sends to MySQL. + # This is useful for debugging but noisy for normal use, so we keep it off. SQLALCHEMY_ECHO = False - # SQLALCHEMY_TRACK_MODIFICATIONS is usually set to False for better performance - # It tells SQLAlchemy not to track every single change to objects + # This is usually False because it avoids extra bookkeeping overhead. + # Keeping it off makes the app lighter and is the normal Flask-SQLAlchemy + # setting for most projects. SQLALCHEMY_TRACK_MODIFICATIONS = False -# Run the MySQL setup check when this config file is imported -# This ensures MySQL is ready before the app tries to use it +# Run the MySQL setup check as soon as config.py is imported. +# That means the app gets a chance to prepare MySQL before Flask starts using +# the database for table creation and queries. try: check_mysql_and_setup() except Exception as e: From 8190aba4b58ed15fdf12b5e392213717c26fb953 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 9 Apr 2026 14:16:47 -0400 Subject: [PATCH 25/68] delete unneeded line --- forum/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/forum/__init__.py b/forum/__init__.py index b566e54..ff71d4f 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -10,7 +10,6 @@ def create_app(): app.register_blueprint(rt) # Set globals from .models import db - from .subforum import db db.init_app(app) with app.app_context(): From 9dc1b6e528dc998a0252596375174c35ee2d14af Mon Sep 17 00:00:00 2001 From: david Date: Thu, 9 Apr 2026 14:56:30 -0400 Subject: [PATCH 26/68] seperate subforum blueprint --- forum/__init__.py | 3 ++- forum/subforum_route.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index ff71d4f..83df93d 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,6 +1,6 @@ from flask import Flask from .routes import rt -from .subforum_route import rt +from .subforum_route import subforum_rt def create_app(): """Construct the core application.""" @@ -8,6 +8,7 @@ def create_app(): app.config.from_object('config.Config') # Register the main routes blueprint. app.register_blueprint(rt) + app.register_blueprint(subforum_rt) # Set globals from .models import db db.init_app(app) diff --git a/forum/subforum_route.py b/forum/subforum_route.py index fb7eded..bf2f508 100644 --- a/forum/subforum_route.py +++ b/forum/subforum_route.py @@ -9,8 +9,8 @@ # The app is small enough to keep in one blueprint for now. from .routes import rt - -@rt.route('/subforum') +subforum_rt = Blueprint('subforum_routes', __name__, template_folder='templates') +@subforum_rt.route('/subforum') def subforum(): # Show one subforum, its posts, and its child subforums. subforum_id = int(request.args.get("sub")) @@ -24,7 +24,7 @@ def subforum(): return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) #@login_required -@rt.route('/createsubforum', methods=['GET', 'POST']) +@subforum_rt.route('/createsubforum', methods=['GET', 'POST']) def create_subforum_page(): # Only admins can create subforums. if not current_user.admin: @@ -78,7 +78,7 @@ def create_subforum_page(): return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) @login_required -@rt.route('/deletesubforum', methods=['POST']) +@subforum_rt.route('/deletesubforum', methods=['POST']) def delete_subforum(): # Only admins can delete subforums. if not current_user.admin: From 161b2afd987d65855a081fa292ba04cd6f919cf4 Mon Sep 17 00:00:00 2001 From: james Date: Thu, 9 Apr 2026 15:36:19 -0400 Subject: [PATCH 27/68] Reactions.py temporary module created. --- forum/Reactions.py | 39 +++++++++++++++++++++++++++++++++++++++ forum/__init__.py | 2 +- forum/routes.py | 1 - 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 forum/Reactions.py diff --git a/forum/Reactions.py b/forum/Reactions.py new file mode 100644 index 0000000..c51dc15 --- /dev/null +++ b/forum/Reactions.py @@ -0,0 +1,39 @@ +#Add to Posts.py eventually + +class Reaction(db.Model): + id = db.Column(db.Integer, primary_key=True) + kind = db.Column(db.String(10)) # 'up', 'down', 'heart' + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + post_id = db.Column(db.Integer, db.ForeignKey('post.id')) + +@rt.route('/action_react', methods=['POST']) +@login_required +def action_react(): + post_id = int(request.form['post_id']) + kind = request.form['kind'] + if kind not in ('up', 'down', 'heart'): + return redirect('/') + existing = Reaction.query.filter_by(post_id=post_id, user_id=current_user.id, kind=kind).first() + if existing: + db.session.delete(existing) + else: + r = Reaction(kind=kind, user_id=current_user.id, post_id=post_id) + db.session.add(r) + db.session.commit() + return redirect('/viewpost?post=' + str(post_id)) + +# Add to Post model so that post.reactions works +reactions = db.relationship('Reaction', backref='post') + +#Add in viewpost.html inside {% block body%} below the of actual post, eventually +
+ + {% for emoji, kind in [('๐Ÿ‘', 'up'), ('๐Ÿ‘Ž', 'down'), ('โค๏ธ', 'heart')] %} + {% set count = post.reactions | selectattr('kind', 'eq', kind) | list | length %} + {% set reacted = current_user.is_authenticated and post.reactions | selectattr('kind','eq',kind) | selectattr('user_id','eq',current_user.id) | list %} + + {% endfor %} +
+ diff --git a/forum/__init__.py b/forum/__init__.py index b3498d7..783197d 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -5,7 +5,7 @@ def create_app(): """Construct the core application.""" app = Flask(__name__, instance_relative_config=False) app.config.from_object('config.Config') - # I think more blueprints might be used to break routes up into things like + #I think more blueprints might be used to break routes up into things like # post_routes # subforum_routes # etc diff --git a/forum/routes.py b/forum/routes.py index f594b2c..46ad63a 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -25,7 +25,6 @@ def action_login(): return render_template("login.html", errors=errors) return redirect("/") - @login_required @rt.route('/action_logout') def action_logout(): From af1196c5906c7c4a1af96b04199e01ad0e6b79bd Mon Sep 17 00:00:00 2001 From: david Date: Thu, 9 Apr 2026 15:47:55 -0400 Subject: [PATCH 28/68] fixed pull request for one file --- forum/__init__.py | 2 +- forum/models.py | 2 - forum/routes.py | 4 +- forum/subforum.py | 121 +++++++++++++++++++++++++++++++++++++--- forum/subforum_route.py | 110 ------------------------------------ 5 files changed, 116 insertions(+), 123 deletions(-) delete mode 100644 forum/subforum_route.py diff --git a/forum/__init__.py b/forum/__init__.py index 83df93d..68af683 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,6 +1,6 @@ from flask import Flask from .routes import rt -from .subforum_route import subforum_rt +from .subforum import subforum_rt def create_app(): """Construct the core application.""" diff --git a/forum/models.py b/forum/models.py index f64fc8c..8e79bf3 100644 --- a/forum/models.py +++ b/forum/models.py @@ -8,8 +8,6 @@ db = SQLAlchemy() -from .subforum import Subforum, generateLinkPath - # Database models class User(UserMixin, db.Model): # Store account information and ownership of posts/comments. diff --git a/forum/routes.py b/forum/routes.py index a0124b4..46a42f0 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,9 +3,9 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, valid_content, valid_title, db, error, generateLinkPath, Subforum +from .models import User, Post, Comment, valid_content, valid_title, db, error from .user import username_taken, email_taken, valid_username -#from .Subform import Subforum, db, generateLinkPath +from .subforum import Subforum, db, generateLinkPath # Route handlers for login, browsing, and content creation. diff --git a/forum/subforum.py b/forum/subforum.py index afb31de..fa6145b 100644 --- a/forum/subforum.py +++ b/forum/subforum.py @@ -1,7 +1,4 @@ -# Shared SQLAlchemy object used by the app factory and all models. -from flask_sqlalchemy import SQLAlchemy - -from .models import db +from .models import db, Post, valid_content, valid_title, error class Subforum(db.Model): # Represent a forum category and its optional child subforums. @@ -21,9 +18,6 @@ def __init__(self, title, description): self.title = title self.description = description -def error(errormessage): - return "" + errormessage + "" - def generateLinkPath(subforumid): links = [] subforum = Subforum.query.filter(Subforum.id == subforumid).first() @@ -44,4 +38,115 @@ def valid_title(title): return len(title) > 4 and len(title) < 140 def valid_content(content): - return len(content) > 10 and len(content) < 5000 \ No newline at end of file + return len(content) > 10 and len(content) < 5000 + + +#routes for subforum browsing and creation + +from flask import Blueprint, render_template, request, redirect +from flask_login import current_user, login_required +from flask_login.utils import login_required +from .models import Post, valid_content, valid_title + +# Route handlers for login, browsing, and content creation. +# The app is small enough to keep in one blueprint for now. + +subforum_rt = Blueprint('subforum_routes', __name__, template_folder='templates') +@subforum_rt.route('/subforum') +def subforum(): + # Show one subforum, its posts, and its child subforums. + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) + subforumpath = subforum.path or generateLinkPath(subforum.id) + + subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() + return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) + +@login_required +@subforum_rt.route('/createsubforum', methods=['GET', 'POST']) +def create_subforum_page(): + # Only admins can create subforums. + if not current_user.admin: + return error("Only administrators can create subforums!") + + # Get the parent subforum if specified + parent_id = request.args.get('parent') or request.form.get('parent_id') + parent_subforum = None + if parent_id: + try: + parent_id = int(parent_id) + parent_subforum = Subforum.query.get(parent_id) + except (ValueError, TypeError): + pass + + if request.method == 'POST': + title = request.form['title'] + description = request.form['description'] + + # Validate input + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(description): + errors.append("Description must be between 10 and 5000 characters long!") + retry = True + + if retry: + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) + + # Create the new subforum + subforum = Subforum(title, description) + + # Add to parent if specified + if parent_subforum: + parent_subforum.subforums.append(subforum) + + db.session.add(subforum) + db.session.commit() + + if parent_subforum: + return redirect("/subforum?sub=" + str(parent_subforum.id)) + else: + return redirect("/") + + # GET request - show the form + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) + +@login_required +@subforum_rt.route('/deletesubforum', methods=['POST']) +def delete_subforum(): + # Only admins can delete subforums. + if not current_user.admin: + return Subforum.error("Only administrators can delete subforums!") + + subforum_id = int(request.form.get('subforum_id')) + subforum = Subforum.query.get(subforum_id) + + if not subforum: + return Subforum.error("That subforum does not exist!") + + # Prevent deletion of protected subforums + if subforum.protected: + return Subforum.error("This subforum is protected and cannot be deleted!") + + # Prevent deletion if subforum has child subforums or posts + if subforum.subforums or subforum.posts: + return Subforum.error("Cannot delete a subforum that contains child subforums or posts!") + + parent_id = subforum.parent_id + db.session.delete(subforum) + db.session.commit() + + # Redirect back to parent or home + if parent_id: + return redirect("/subforum?sub=" + str(parent_id)) + else: + return redirect("/") + diff --git a/forum/subforum_route.py b/forum/subforum_route.py deleted file mode 100644 index bf2f508..0000000 --- a/forum/subforum_route.py +++ /dev/null @@ -1,110 +0,0 @@ -from flask import render_template, request, redirect, url_for -from flask_login import current_user, login_user, logout_user -from flask_login.utils import login_required -import datetime -from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, valid_content, valid_title, db, error, generateLinkPath, Subforum - -# Route handlers for login, browsing, and content creation. -# The app is small enough to keep in one blueprint for now. - -from .routes import rt -subforum_rt = Blueprint('subforum_routes', __name__, template_folder='templates') -@subforum_rt.route('/subforum') -def subforum(): - # Show one subforum, its posts, and its child subforums. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return error("That subforum does not exist!") - posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) - subforumpath = subforum.path or generateLinkPath(subforum.id) - - subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() - return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) - -#@login_required -@subforum_rt.route('/createsubforum', methods=['GET', 'POST']) -def create_subforum_page(): - # Only admins can create subforums. - if not current_user.admin: - return error("Only administrators can create subforums!") - - # Get the parent subforum if specified - parent_id = request.args.get('parent') or request.form.get('parent_id') - parent_subforum = None - if parent_id: - try: - parent_id = int(parent_id) - parent_subforum = Subforum.query.get(parent_id) - except (ValueError, TypeError): - pass - - if request.method == 'POST': - title = request.form['title'] - description = request.form['description'] - - # Validate input - errors = [] - retry = False - if not valid_title(title): - errors.append("Title must be between 4 and 140 characters long!") - retry = True - if not valid_content(description): - errors.append("Description must be between 10 and 5000 characters long!") - retry = True - - if retry: - subforums = Subforum.query.filter(Subforum.parent_id == None).all() - return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) - - # Create the new subforum - subforum = Subforum(title, description) - - # Add to parent if specified - if parent_subforum: - parent_subforum.subforums.append(subforum) - - db.session.add(subforum) - db.session.commit() - - if parent_subforum: - return redirect("/subforum?sub=" + str(parent_subforum.id)) - else: - return redirect("/") - - # GET request - show the form - subforums = Subforum.query.filter(Subforum.parent_id == None).all() - return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) - -@login_required -@subforum_rt.route('/deletesubforum', methods=['POST']) -def delete_subforum(): - # Only admins can delete subforums. - if not current_user.admin: - return error("Only administrators can delete subforums!") - - subforum_id = int(request.form.get('subforum_id')) - subforum = Subforum.query.get(subforum_id) - - if not subforum: - return error("That subforum does not exist!") - - # Prevent deletion of protected subforums - if subforum.protected: - return error("This subforum is protected and cannot be deleted!") - - # Prevent deletion if subforum has child subforums or posts - if subforum.subforums or subforum.posts: - return error("Cannot delete a subforum that contains child subforums or posts!") - - parent_id = subforum.parent_id - db.session.delete(subforum) - db.session.commit() - - # Redirect back to parent or home - if parent_id: - return redirect("/subforum?sub=" + str(parent_id)) - else: - return redirect("/") - From 219fa672883a83c66b09ffc11319cd4d02f85e11 Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 15:56:36 -0400 Subject: [PATCH 29/68] IMG and VID not finished but having a stroke --- config.py | 2 ++ forum/post.py | 4 +++- forum/post_routes.py | 14 ++++++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/config.py b/config.py index da78fea..8881ebe 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,8 @@ class Config: # General Config SECRET_KEY = 'kristofer' FLASK_APP = 'forum.app' + UPLOAD_FOLDER = 'forum/static/uploads' + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'webm'} # Database SQLALCHEMY_DATABASE_URI = 'sqlite:///circuscircus.db' diff --git a/forum/post.py b/forum/post.py index c883b57..0293cf0 100644 --- a/forum/post.py +++ b/forum/post.py @@ -6,6 +6,7 @@ class Post(db.Model): # replies point to their parent via parent_id. id = db.Column(db.Integer, primary_key=True) title = db.Column(db.Text, nullable=True) + upload_file = db.Column(db.String(255), nullable=True) content = db.Column(db.Text) postdate = db.Column(db.DateTime) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) @@ -17,10 +18,11 @@ class Post(db.Model): lastcheck = None savedresponse = None - def __init__(self, title=None, content=None, postdate=None): + def __init__(self, title=None, content=None, postdate=None, upload_file=None): self.title = title self.content = content self.postdate = postdate + self.upload_file = upload_file def get_time_string(self): # Recalculate every 30 seconds to avoid inaccurate time labels diff --git a/forum/post_routes.py b/forum/post_routes.py index 65b50d7..752f43b 100644 --- a/forum/post_routes.py +++ b/forum/post_routes.py @@ -1,11 +1,12 @@ -from flask import render_template, request, redirect, url_for +from flask import Blueprint, render_template, request, redirect, url_for, current_app from flask_login import current_user, login_user, logout_user from flask_login.utils import login_required import datetime -from flask import Blueprint, render_template, request, redirect, url_for from .models import User, Post, Subforum, valid_content, valid_title, db, generateLinkPath, error from .user import username_taken, email_taken, valid_username from .routes import rt +import os +from werkzeug.utils import secure_filename @rt.route('/addpost') @@ -40,7 +41,7 @@ def comment(): return error("That post does not exist!") content = request.form['content'] postdate = datetime.datetime.now() - reply = Post(content, postdate) + reply = Post(content=content, postdate=postdate) reply.parent_id = post_id current_user.posts.append(reply) db.session.commit() @@ -68,7 +69,12 @@ def action_post(): retry = True if retry: return render_template("createpost.html", subforum=subforum, errors=errors) - post = Post(title, content, datetime.datetime.now()) + file = request.files.get('upload_file') + filename = None + if file and file.filename: + filename = secure_filename(file.filename) + file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) + post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename) subforum.posts.append(post) user.posts.append(post) db.session.commit() From 39ca89df59aa9006fb0c8620002ec98282930bed Mon Sep 17 00:00:00 2001 From: emoyer Date: Thu, 9 Apr 2026 16:32:21 -0400 Subject: [PATCH 30/68] Fixed HTML --- CIRCUSCIRCUS_FULL_CODE_GUIDE.md | 890 ++++++++++++++++++++++++++++++++ MYSQL_MIGRATION_GUIDE.md | 2 +- README.md | 9 +- forum/static/style.css | 9 + forum/templates/header.html | 2 +- forum/templates/layout.html | 4 +- forum/templates/subforum.html | 2 +- forum/templates/viewpost.html | 10 +- 8 files changed, 915 insertions(+), 13 deletions(-) create mode 100644 CIRCUSCIRCUS_FULL_CODE_GUIDE.md diff --git a/CIRCUSCIRCUS_FULL_CODE_GUIDE.md b/CIRCUSCIRCUS_FULL_CODE_GUIDE.md new file mode 100644 index 0000000..69a77b1 --- /dev/null +++ b/CIRCUSCIRCUS_FULL_CODE_GUIDE.md @@ -0,0 +1,890 @@ +# CircusCircus Beginner-to-Advanced Code Guide + +This guide explains the whole project in plain language first, then technical detail second. + +Goal of this guide: + +- Explain what the project does. +- Explain what every important file does. +- Explain what each function/method does and why. +- Explain the Python operators and patterns used in this codebase. +- Explain classes, objects, and instance variables in this project. +- Explain why MySQL is configured this way. + +If you are new, start at Section 1 and go in order. + +## 1) One-Sentence Project Summary + +CircusCircus is a Flask forum app where users can register, log in, browse subforums, create posts, and add comments. + +## 2) What Happens When You Run It + +You run: + +```bash +bash ./run.sh +``` + +Then the app does this: + +1. Creates a virtual environment if needed. +2. Installs Python packages from requirements.txt. +3. Sets default environment variables for MySQL and Flask. +4. Starts Flask using forum.app. +5. Loads config.py (which checks MySQL setup). +6. Builds app + routes. +7. Creates database tables if missing. +8. Seeds default subforums if database is empty. +9. Serves pages in browser. + +## 3) Why MySQL Is Configured This Way + +This is very important for your team. + +### Problem being solved + +A MySQL app needs more setup than SQLite: + +- server must be running +- database must exist +- user account must exist +- user must have permission to that database +- app must have correct connection string + +If any one of these is wrong, app startup fails. + +### Why config.py does setup checks + +config.py tries to reduce first-run setup pain by: + +- attempting a local admin connection +- creating database/user if possible +- checking app login credentials +- printing clear messages when setup cannot be done automatically + +This means teammates do not need to memorize SQL commands just to run locally. + +### Why defaults are what they are + +The defaults in this project are: + +- DB_USER = zipchat_app +- DB_PASSWORD = password +- DB_HOST = 127.0.0.1 +- DB_PORT = 3306 +- DB_NAME = ZipChat + +Reason: + +- predictable local setup +- fewer required manual exports +- consistent behavior across machines + +### Why root/admin is not used for normal app queries + +The app should not run day-to-day as root because: + +- root can alter everything in the server +- app bugs become more dangerous +- principle of least privilege says give only needed rights + +So setup uses root/admin only for creation/grants, then app runs as zipchat_app. + +### Why cryptography dependency exists + +Modern MySQL often uses caching_sha2_password. +PyMySQL needs cryptography installed to handle that authentication flow. +Without it, login can fail even if username/password are correct. + +### Why some model fields changed from Text to String + +MySQL does not allow UNIQUE indexes on plain TEXT columns without key length. +The app needed unique username/email/title behavior, so those became bounded String fields. +This keeps behavior correct and MySQL-compatible. + +## 4) Python Basics Used in This Project + +This section explains symbols/operators you see in the code. + +### Assignment and values + +- = + - Assigns a value. + - Example idea: x = 5 means store 5 in x. + +- return + - Sends a value back from a function. + +### Comparison operators + +- == : equal to +- != : not equal to +- > : greater than +- < : less than +- >= : greater than or equal +- <= : less than or equal + +Used in validation and query checks. + +### Boolean logic + +- and : both conditions true +- or : either condition true +- not : invert true/false + +Used heavily in form validation and condition branching. + +### Identity / null checks + +- is None + - checks whether a variable is the special None value +- is not None + - opposite of above + +Used when checking if queries found a record. + +### Membership and iteration + +- in + - checks membership or iterates over lists + +Used in loops through posts/subforums/comments. + +### Control flow keywords + +- if / elif / else + - choose one branch based on condition +- for + - loop over collection +- while + - loop while condition holds (used less here) +- try / except + - catch and handle runtime errors cleanly + +Used in MySQL setup where connections can fail for many reasons. + +### Function and class syntax + +- def function_name(...): + - define a function +- class ClassName(...): + - define a class + +### Decorators you see in Flask code + +- @rt.route(...) + - registers URL route for a function +- @login_required + - blocks route unless user is authenticated +- @login_manager.user_loader + - tells Flask-Login how to reload user from session id + +## 5) Classes, Objects, and Instance Variables (In This App) + +### Class + +A class is a blueprint. +Example: User class defines what a user record looks like. + +### Object (instance) + +An object is one real thing created from a class. +Example: one registered person is one User object. + +### Instance variable + +An instance variable is data stored on one object. +Examples in User: + +- self.username +- self.email +- self.password_hash + +Each user object has its own values. + +### Class variable (important note) + +If a variable is defined directly inside class body (not in __init__), it can act like shared class-level data. +In Post and Comment, lastcheck/savedresponce are declared at class level, so understand they are not typical per-row database columns. + +## 6) Full File-by-File Explanation + +## 6.1 run.sh + +Purpose: + +- one command local startup script + +What each step does: + +1. set -e + +- stop script if a command fails + +1. find script folder and cd into it + +- ensures consistent working directory + +1. create .venv if missing + +- isolates Python dependencies for this project + +1. activate .venv and install requirements + +- guarantees modules like Flask/PyMySQL exist + +1. export defaults for Flask + MySQL + +- avoids requiring manual environment setup on every run + +1. run flask + +- starts app server + +Why this file exists: + +- standardizes startup for every teammate + +## 6.2 config.py + +Purpose: + +- app configuration + MySQL setup checks + +Functions: + +1. try_admin_connection() + +- tries local MySQL admin access in multiple ways +- returns connection + method string on success +- returns None tuple on failure +- Why: local MySQL access varies by machine + +1. setup_database_and_user() + +- creates database ZipChat if missing +- creates zipchat_app if missing +- grants privileges +- Why: first run should be easy + +1. check_mysql_connection() + +- tests app-level login with DB_* settings +- translates common MySQL error codes into readable messages +- Why: actionable startup feedback + +1. check_mysql_and_setup() + +- runs setup then verification in one place +- Why: predictable boot order + +Class Config: + +- holds Flask settings and SQLAlchemy database URI +- Why: single source of truth for app config + +Key instance-like settings in Config: + +- SECRET_KEY +- FLASK_APP +- DB_USER +- DB_PASSWORD +- DB_HOST +- DB_PORT +- DB_NAME +- SQLALCHEMY_DATABASE_URI +- SQLALCHEMY_ECHO +- SQLALCHEMY_TRACK_MODIFICATIONS + +## 6.3 forum/__init__.py + +Purpose: + +- app factory module + +Function: create_app() + +- builds Flask app object +- loads Config from config.py +- registers routes blueprint +- binds SQLAlchemy db object +- Why: clean startup composition and easier maintenance + +## 6.4 forum/app.py + +Purpose: + +- runtime entry module + seed logic + home route + +Function: init_site() + +- creates default forum categories on first run +- Why: user sees usable forum immediately + +Function: add_subforum(title, description, parent=None) + +- creates one subforum if not duplicate in same level +- attaches to parent if provided +- commits change +- Why: reusable seeding helper + +Function: load_user(userid) + +- fetches user by ID for Flask-Login +- Why: required session restoration hook + +Route: index() + +- URL / +- reads top-level subforums +- renders subforums.html +- Why: landing page of app + +Startup app_context block: + +- runs db.create_all() +- seeds subforums if none exist +- Why: ensure data structures are ready + +## 6.5 forum/models.py + +Purpose: + +- data schema + model behavior helpers + +Global: db = SQLAlchemy() + +- shared ORM object used by app factory and models + +Class User(UserMixin, db.Model) +Instance variables/columns: + +- id: primary key integer +- username: unique login/display name +- password_hash: secure hashed password +- email: unique email +- admin: boolean role flag +Relationships: +- posts: user has many posts +- comments: user has many comments +Methods: +- __init__(email, username, password): stores hashed password +- check_password(password): verifies password attempt +Why class exists: +- stores identity + authentication + +Class Post(db.Model) +Columns: + +- id, title, content, user_id, subforum_id, postdate +Relationship: +- comments +Methods: +- __init__(title, content, postdate) +- get_time_string(): human-friendly time label +Why class exists: +- stores forum threads + +Class Subforum(db.Model) +Columns: + +- id, title, description, parent_id, hidden +Relationships: +- subforums (children) +- posts +Method: +- __init__(title, description) +Why class exists: +- stores category tree + +Class Comment(db.Model) +Columns: + +- id, content, postdate, user_id, post_id +Methods: +- __init__(content, postdate) +- get_time_string() +Why class exists: +- stores responses under posts + +Helper function: error(errormessage) + +- returns simple red HTML message string +- Why: quick inline error output + +Helper function: generateLinkPath(subforumid) + +- builds breadcrumb links up parent chain +- Why: consistent navigation path text + +Helper function: valid_title(title) + +- checks title length limits +- Why: basic quality control + +Helper function: valid_content(content) + +- checks post body length limits +- Why: basic quality control + +## 6.6 forum/user.py + +Purpose: + +- lightweight account validation helpers + +Variables: + +- password_regex: allowed password format +- username_regex: allowed username format + +Functions: + +1. valid_username(username) + +- regex check for username +- Why: prevent invalid names + +1. valid_password(password) + +- regex check for password format +- Why: optional password policy helper + +1. username_taken(username) + +- query existing user +- Why: avoid duplicates + +1. email_taken(email) + +- query existing email +- Why: avoid duplicate accounts + +## 6.7 forum/routes.py + +Purpose: + +- all route handlers for auth + browsing + posting + comments + +Blueprint variable: + +- rt +- Why: register group of routes into app factory + +Routes and why each exists: + +1. action_login() -> /action_login (POST) + +- verifies credentials +- starts user session +- Why: login workflow + +1. action_logout() -> /action_logout (GET, login_required) + +- ends session +- Why: logout workflow + +1. action_createaccount() -> /action_createaccount (POST) + +- validates inputs +- creates user +- logs in user +- Why: signup workflow + +1. subforum() -> /subforum (GET) + +- loads one category and its posts/subcategories +- Why: category browsing + +1. loginform() -> /loginform (GET) + +- renders login/register page +- Why: UI entry for auth + +1. addpost() -> /addpost (GET, login_required) + +- shows create post form +- Why: start post creation + +1. viewpost() -> /viewpost (GET) + +- shows post + comments +- Why: post detail page + +1. comment() -> /action_comment (POST/GET, login_required) + +- creates and saves comment +- Why: user interaction under posts + +1. action_post() -> /action_post (POST, login_required) + +- validates + saves new post +- Why: create content + +## 6.8 Templates and Static Files + +Templates folder explains UI pages: + +- layout.html: main page shell +- header.html: top bar with auth status +- subforums.html: top categories page +- subforum.html: category detail page +- viewpost.html: post + comments page +- createpost.html: post form page +- login.html: login + signup forms + +Static files: + +- bootstrap.min.css: framework styles +- style.css: project custom styles + +## 7) How to Read and Understand the Script (Recommended Team Workflow) + +Use this order every time: + +1. Read run.sh + +- understand startup environment and defaults + +1. Read config.py + +- understand MySQL behavior and URI construction + +1. Read forum/__init__.py + +- understand how app is assembled + +1. Read forum/app.py + +- understand startup table creation and seed logic + +1. Read forum/models.py + +- understand data structure and relationships + +1. Read forum/routes.py + +- understand user behavior endpoint by endpoint + +1. Read templates alongside each route + +- understand how backend data appears to user + +Debugging method: + +- Start from URL route function. +- Identify model queries/writes. +- Identify template rendered. +- Confirm commit/redirect path. + +## 8) Common Confusions (Beginner Notes) + +1. Why do we call db.create_all() on startup? + +- To create missing tables automatically in dev/local runs. + +1. Why can setup warning appear but app still work? + +- Admin setup can fail while app user login still succeeds. + +1. Why not hardcode root in app connection? + +- Root is too powerful for normal app runtime. + +1. Why environment variables and defaults both? + +- Defaults make local run easy. +- Environment variables allow overrides for different machines. + +## 9) Short Team Script (Say This in Standup) + +"CircusCircus is a Flask + MySQL forum app. run.sh starts local environment and flask. config.py checks MySQL setup and builds DB connection. models.py defines users/posts/subforums/comments. routes.py handles login, signup, browsing, posting, and comments. app.py creates tables and seeds default subforums. Templates render each page." + +That gives everyone the same mental model quickly. + +## 10) In-Depth Appendix: How and Why the System Works + +This section is intentionally deeper than the rest of the guide. It is for anyone who wants to understand design choices, side effects, and implementation mechanics beyond the beginner view. + +### 10.1 Application Lifecycle (Import Time vs Runtime) + +The app has two important phases: + +1. Import-time phase + +- Python imports modules. +- config.py executes top-level code. +- check_mysql_and_setup() runs during import. + +1. Runtime phase + +- Flask app object handles HTTP requests. +- Route functions execute per request. +- Templates are rendered into HTML responses. + +Why this distinction matters: + +- Import-time code runs once when process starts. +- Runtime code runs for every request. +- Heavy or risky operations in import-time code can delay startup. +- This project intentionally accepts some startup work to make local setup easier. + +### 10.2 Request Lifecycle (Step-by-Step) + +For one HTTP request, Flask does roughly this: + +1. Accept incoming URL and method. +2. Find matching route function. +3. Build request object (query params, form values, headers). +4. Execute route Python code. +5. Route either: + +- returns render_template(...) +- returns redirect(...) +- returns string/response body + +1. Flask sends response to browser. + +In this project: + +- Read routes usually query ORM then render template. +- Write routes usually validate inputs, mutate ORM objects, commit, then redirect. + +Why redirect after writes: + +- Prevents duplicate form submissions on page refresh. +- Follows Post/Redirect/Get pattern. + +### 10.3 SQLAlchemy Relationship Behavior (Why append Works) + +You will see patterns like: + +- user.posts.append(post) +- subforum.posts.append(post) + +Why this works: + +- SQLAlchemy tracks object relationships in memory. +- When db.session.commit() runs, SQLAlchemy computes needed foreign keys and INSERT/UPDATE statements. + +Benefit: + +- Route code stays object-oriented and readable. + +Risk to know: + +- If you forget commit, changes stay only in memory and are lost after request ends. + +### 10.4 Transaction and Error Behavior + +Each write route commits once after all changes are prepared. + +Current behavior: + +- No explicit rollback calls in routes. +- If commit throws, request errors and Flask returns stack trace in debug mode. + +Why that is acceptable here: + +- Project is educational/small. +- Simpler code is easier for beginners. + +What production would add: + +- try/except around commits +- session rollback on failure +- structured error responses/logging + +### 10.5 Data Model Design Choices + +User table: + +- unique username/email protects identity collisions. +- password_hash stores derived hash, never plain password. + +Post table: + +- links to one user and one subforum. +- title uses bounded String for index friendliness. + +Subforum table: + +- parent_id enables hierarchy (tree-like structure). +- self-relationship allows child categories. + +Comment table: + +- links to one user and one post. + +Why this shape: + +- Matches forum domain naturally. +- Keeps read queries simple for common pages. + +### 10.6 Time String Helpers (get_time_string) + +Post and Comment include get_time_string() to display relative age. + +How it works: + +- compute now - postdate/commentdate +- convert seconds into months/days/hours/minutes buckets + +Design note: + +- It uses cached fields lastcheck/savedresponce declared at class level. +- In strict OO terms, this can behave like shared state unless overwritten per instance. + +Why you might revisit this later: + +- To avoid accidental shared caching behavior across instances. +- A safer pattern is pure calculation without mutable shared fields. + +### 10.7 Authentication Model and Security Notes + +What is secure now: + +- Passwords are hashed via werkzeug helpers. +- Session user loading uses Flask-Login. +- Protected routes use login_required decorator. + +What is simplified for this project: + +- Username 'admin' auto-flags admin status during signup. +- This is convenient but weak for real authorization controls. + +Production hardening ideas: + +- Separate admin promotion workflow. +- CSRF protection checks on forms. +- Rate limiting on login endpoint. +- Stronger password policy enforcement (valid_password currently not enforced). + +### 10.8 MySQL Deep Rationale + +This app uses MySQL with a specific local-first strategy. + +Why not rely only on manual SQL setup: + +- New teammates get blocked quickly. +- Setup docs drift over time. +- Team onboarding becomes inconsistent. + +Why check_mysql_and_setup exists: + +- Tries to self-heal common missing pieces. +- Gives explicit fixes when self-heal cannot run. + +Why admin connection attempts multiple methods: + +- MySQL installation method changes access pattern: + - socket path + - localhost TCP without password + - localhost TCP with root password + +Why app connects as zipchat_app and not root: + +- Principle of least privilege. +- Better blast-radius control. +- Cleaner separation between setup role and runtime role. + +Why String lengths matter in MySQL: + +- UNIQUE index on TEXT without key length fails. +- Bounded String columns avoid that DDL error. + +### 10.9 Template Rendering Model + +This app uses server-side templates (Jinja): + +- Python route returns data +- Jinja merges data into HTML +- Browser receives ready-to-display page + +Why this approach: + +- Easy to reason about for beginners. +- No separate frontend build toolchain. + +Tradeoff: + +- Less dynamic than SPA frameworks. +- More full-page reloads. + +### 10.10 Common Failure Modes and Meaning + +1. MySQL auth failure (1045) + +- Meaning: bad user/password or user host mismatch. + +1. Unknown database (1049) + +- Meaning: DB name exists in config but not in MySQL. + +1. Can't connect (2003) + +- Meaning: MySQL process not reachable on host/port. + +1. DDL/index errors when creating tables + +- Meaning: model definitions incompatible with MySQL rules. + +Troubleshooting order: + +1. Confirm MySQL is running. +2. Confirm DB_* values. +3. Confirm user privileges. +4. Re-run startup and read first error message, not only bottom stack trace. + +### 10.11 Code Quality and Maintainability Observations + +Current strengths: + +- Small, readable structure. +- Clear route to template mapping. +- Straightforward ORM model design. + +Current weak spots to track: + +- Duplicate import line in routes.py. +- Mixed tab/space style in some files. +- Broad except Exception in setup code. +- Some helper functions return raw HTML strings. + +Safe improvement path: + +1. Keep behavior same. +2. Refactor one module at a time. +3. Add tests around auth + create post/comment flows. +4. Introduce migrations (Alembic/Flask-Migrate) for schema evolution. + +### 10.12 Mental Model for New Contributors + +If you are changing feature behavior, follow this mental checklist: + +1. Which URL handles this behavior? +2. Which route function owns that URL? +3. Which model objects are read or written? +4. Which template renders the result? +5. Which validation rules gate the write? +6. Where is commit called? +7. How would this fail if DB/auth/config changed? + +If you can answer those seven questions, you understand both how and why the script works for that feature. + +### 10.13 FAQ-Style Deep Answers + +Q: Why does config.py do work instead of only storing constants? +A: To improve local startup reliability and reduce manual setup friction for developers. + +Q: Is import-time DB setup always best practice? +A: Not always. It is a practical choice here for simplicity. In larger systems, setup is often moved to explicit migration/deployment commands. + +Q: Why not use SQLite for simplicity? +A: Team chose MySQL. MySQL better matches multi-user server deployments and privilege models. + +Q: Why are writes followed by redirect instead of rendering directly? +A: Redirect reduces accidental duplicate form submissions and keeps URL state clean. + +Q: Why keep both beginner and in-depth sections in one guide? +A: Team members have different experience levels. One file supports fast onboarding and deeper maintenance knowledge. diff --git a/MYSQL_MIGRATION_GUIDE.md b/MYSQL_MIGRATION_GUIDE.md index 92e2899..16bbb38 100644 --- a/MYSQL_MIGRATION_GUIDE.md +++ b/MYSQL_MIGRATION_GUIDE.md @@ -151,4 +151,4 @@ The app now has a simpler onboarding story: - have the database schema created automatically - use a predictable app user and database name -That is the version of the project you can hand to a teammate without asking them to understand the whole migration history first. \ No newline at end of file +That is the version of the project you can hand to a teammate without asking them to understand the whole migration history first. diff --git a/README.md b/README.md index ecf1fe9..08683e2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # CircusCircus + This is a minimal forum written in python with Flask. It supports only the bare minumum of features to allow discussions, including user accounts, threads, and comments. On first run, the default subforums will be created. Although custom subforums are not supported through any user interface, it is possible to modify forum/setup.py to create custom subforums. @@ -46,10 +47,10 @@ This currently puts a sqlite3 db in the /tmp directory. (use atleast python 3.11) ``` -$ python3.11 -m venv venv -$ source venv/bin/activate -$ pip install -r requirements.txt -$ ./run.sh +python3.11 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +./run.sh ``` and it should appear on port 5000 diff --git a/forum/static/style.css b/forum/static/style.css index 2dd2e2e..f23281d 100644 --- a/forum/static/style.css +++ b/forum/static/style.css @@ -41,6 +41,15 @@ padding-left: 0.5%; font-size: 12pt; } +.adminusername{ + color: red; +} +.centertext{ + text-align: center; +} +.comment-submit{ + margin-bottom: 1%; +} .postsubmit{ margin-left: auto; margin-right: auto; diff --git a/forum/templates/header.html b/forum/templates/header.html index 9403a0d..5bf57b4 100644 --- a/forum/templates/header.html +++ b/forum/templates/header.html @@ -1,7 +1,7 @@ {{ config.SITE_NAME }}{% if config.SITE_DESCRIPTION %} - {% endif %} {{ config.SITE_DESCRIPTION }}
- by {{post.user.username}} + by {{post.user.username}}
{{ post.get_time_string() }}
diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 3f489ca..d12dbe6 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -5,7 +5,7 @@
{{post.title}} -
+
{{ post.user.username }}
@@ -20,11 +20,11 @@
-
- +
+
-
+
@@ -41,7 +41,7 @@
- ({{ comment.user.username }}) - + ({{ comment.user.username }}) -
{{ comment.content }} From dd450748a360f48347b297dda760816e733627f7 Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 16:53:13 -0400 Subject: [PATCH 31/68] IMG and VID finished --- forum/post_routes.py | 6 +++++- forum/templates/createpost.html | 3 ++- forum/templates/viewpost.html | 10 ++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/forum/post_routes.py b/forum/post_routes.py index 752f43b..82ecd92 100644 --- a/forum/post_routes.py +++ b/forum/post_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, current_app +from flask import Blueprint, render_template, request, redirect, url_for, current_app, send_from_directory from flask_login import current_user, login_user, logout_user from flask_login.utils import login_required import datetime @@ -9,6 +9,10 @@ from werkzeug.utils import secure_filename +@rt.route('/uploads/') +def uploaded_file(filename): + return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename) + @rt.route('/addpost') @login_required def addpost(): diff --git a/forum/templates/createpost.html b/forum/templates/createpost.html index 85947a6..a706c4d 100644 --- a/forum/templates/createpost.html +++ b/forum/templates/createpost.html @@ -9,12 +9,13 @@ You are posting to {{ subforum.title }}
-
+

+
diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 3f489ca..66b60a6 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -16,6 +16,16 @@
{{post.content}}
+ {% if post.upload_file %} + {% if post.upload_file.endswith(('.mp4', 'mov', '.webm')) %} + + {% else %} + + {% endif %} + {% endif %} +
From c4c4c7f9ce6f546bd598f10f5ba54c823c782363 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 9 Apr 2026 18:10:39 -0400 Subject: [PATCH 32/68] fixed some merge errors --- forum/models.py | 174 ++++++++++++++++++++----------------------- forum/post_routes.py | 4 +- forum/routes.py | 2 +- forum/subforum.py | 4 +- run.sh | 1 - 5 files changed, 85 insertions(+), 100 deletions(-) diff --git a/forum/models.py b/forum/models.py index 024ae86..3bd9076 100644 --- a/forum/models.py +++ b/forum/models.py @@ -29,67 +29,51 @@ def check_password(self, password): return check_password_hash(self.password_hash, password) - -class Subforum(db.Model): - # Represent a forum category and its optional child subforums. - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text, unique=True) - description = db.Column(db.Text) - subforums = db.relationship("Subforum") - parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - posts = db.relationship("Post", backref="subforum") - path = None - hidden = db.Column(db.Boolean, default=False) - - def __init__(self, title, description): - self.title = title - self.description = description - # Post is defined in post.py; imported here after db is ready to avoid # circular imports while keeping Post in its own module. -from .post import Post # noqa: E402 -class Post(db.Model): - # Store one forum post and link it to a user and subforum. - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(140), nullable=False) - content = db.Column(db.Text) - comments = db.relationship("Comment", backref="post") - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - postdate = db.Column(db.DateTime) - - # Simple in-memory cache for human-readable time labels. - lastcheck = None - savedresponce = None - - def __init__(self, title, content, postdate): - self.title = title - self.content = content - self.postdate = postdate - - def get_time_string(self): - # Only recalculate the label every 30 seconds. - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - - return self.savedresponce +# from .post import Post # noqa: E402 +# class Post(db.Model): +# # Store one forum post and link it to a user and subforum. +# id = db.Column(db.Integer, primary_key=True) +# title = db.Column(db.String(140), nullable=False) +# content = db.Column(db.Text) +# comments = db.relationship("Comment", backref="post") +# user_id = db.Column(db.Integer, db.ForeignKey('user.id')) +# subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) +# postdate = db.Column(db.DateTime) + +# # Simple in-memory cache for human-readable time labels. +# lastcheck = None +# savedresponce = None + +# def __init__(self, title, content, postdate): +# self.title = title +# self.content = content +# self.postdate = postdate + +# def get_time_string(self): +# # Only recalculate the label every 30 seconds. +# now = datetime.datetime.now() +# if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: +# self.lastcheck = now +# else: +# return self.savedresponce + +# diff = now - self.postdate + +# seconds = diff.total_seconds() +# if seconds / (60 * 60 * 24 * 30) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" +# elif seconds / (60 * 60 * 24) > 1: +# self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" +# elif seconds / (60 * 60) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" +# elif seconds / (60) > 1: +# self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" +# else: +# self.savedresponce = "Just a moment ago!" + +# return self.savedresponce # class Subforum(db.Model): # # Represent a forum category and its optional child subforums. @@ -106,42 +90,42 @@ def get_time_string(self): # self.title = title # self.description = description -class Comment(db.Model): - # Store a comment attached to a post and authored by a user. - id = db.Column(db.Integer, primary_key=True) - content = db.Column(db.Text, nullable=False) - postdate = db.Column(db.DateTime) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - post_id = db.Column(db.Integer, db.ForeignKey("post.id")) - - lastcheck = None - savedresponce = None - - def __init__(self, content, postdate): - self.content = content - self.postdate = postdate - - def get_time_string(self): - # Only recalculate the label every 30 seconds. - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - return self.savedresponce +# class Comment(db.Model): +# # Store a comment attached to a post and authored by a user. +# id = db.Column(db.Integer, primary_key=True) +# content = db.Column(db.Text, nullable=False) +# postdate = db.Column(db.DateTime) +# user_id = db.Column(db.Integer, db.ForeignKey('user.id')) +# post_id = db.Column(db.Integer, db.ForeignKey("post.id")) + +# lastcheck = None +# savedresponce = None + +# def __init__(self, content, postdate): +# self.content = content +# self.postdate = postdate + +# def get_time_string(self): +# # Only recalculate the label every 30 seconds. +# now = datetime.datetime.now() +# if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: +# self.lastcheck = now +# else: +# return self.savedresponce + +# diff = now - self.postdate +# seconds = diff.total_seconds() +# if seconds / (60 * 60 * 24 * 30) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" +# elif seconds / (60 * 60 * 24) > 1: +# self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" +# elif seconds / (60 * 60) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" +# elif seconds / (60) > 1: +# self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" +# else: +# self.savedresponce = "Just a moment ago!" +# return self.savedresponce def error(errormessage): return "" + errormessage + "" diff --git a/forum/post_routes.py b/forum/post_routes.py index 82ecd92..1e8ca1e 100644 --- a/forum/post_routes.py +++ b/forum/post_routes.py @@ -2,11 +2,13 @@ from flask_login import current_user, login_user, logout_user from flask_login.utils import login_required import datetime -from .models import User, Post, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .models import User, valid_content, valid_title, db, error from .user import username_taken, email_taken, valid_username from .routes import rt import os from werkzeug.utils import secure_filename +from .post import Post +from .subforum import Subforum @rt.route('/uploads/') diff --git a/forum/routes.py b/forum/routes.py index 2ad0cbb..43eedbf 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,7 +3,7 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, valid_content, valid_title, db, error +from .models import User, valid_content, valid_title, db, error from .user import username_taken, email_taken, valid_username from .subforum import Subforum, db, generateLinkPath diff --git a/forum/subforum.py b/forum/subforum.py index fa6145b..d2264f8 100644 --- a/forum/subforum.py +++ b/forum/subforum.py @@ -1,4 +1,5 @@ -from .models import db, Post, valid_content, valid_title, error +from .models import db, valid_content, valid_title, error +from .post import Post class Subforum(db.Model): # Represent a forum category and its optional child subforums. @@ -46,7 +47,6 @@ def valid_content(content): from flask import Blueprint, render_template, request, redirect from flask_login import current_user, login_required from flask_login.utils import login_required -from .models import Post, valid_content, valid_title # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. diff --git a/run.sh b/run.sh index 24ed0f0..62f08b9 100755 --- a/run.sh +++ b/run.sh @@ -14,7 +14,6 @@ fi pip install -r requirements.txt >/dev/null export SECRET_KEY="${SECRET_KEY:-kristofer}" -export FLASK_APP=forum.app export PORT="${PORT:-8000}" # MySQL-only configuration. From de67e813d6786fb0b5d839c06d8e3e88923bad90 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 9 Apr 2026 18:33:37 -0400 Subject: [PATCH 33/68] forums is working, but not all features in yet. Uploads do not work correctly. --- forum/post_routes.py | 2 +- forum/subforum.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/forum/post_routes.py b/forum/post_routes.py index 1e8ca1e..5478503 100644 --- a/forum/post_routes.py +++ b/forum/post_routes.py @@ -8,7 +8,7 @@ import os from werkzeug.utils import secure_filename from .post import Post -from .subforum import Subforum +from .subforum import Subforum, generateLinkPath @rt.route('/uploads/') diff --git a/forum/subforum.py b/forum/subforum.py index d2264f8..4c2d1aa 100644 --- a/forum/subforum.py +++ b/forum/subforum.py @@ -4,7 +4,7 @@ class Subforum(db.Model): # Represent a forum category and its optional child subforums. id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text, unique=True) + title = db.Column(db.String(255), unique=True) description = db.Column(db.Text) subforums = db.relationship("Subforum") parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) From cabda6b26a465f516bb480ec62980709280eac0e Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 18:28:15 -0400 Subject: [PATCH 34/68] Post publicity --- forum/post.py | 5 ++++- forum/post_routes.py | 5 ++++- forum/routes.py | 24 ++++++++++++------------ forum/templates/createpost.html | 1 + forum/templates/viewpost.html | 1 + 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/forum/post.py b/forum/post.py index 0293cf0..3d15ad8 100644 --- a/forum/post.py +++ b/forum/post.py @@ -8,21 +8,24 @@ class Post(db.Model): title = db.Column(db.Text, nullable=True) upload_file = db.Column(db.String(255), nullable=True) content = db.Column(db.Text) + private = db.Column(db.Boolean, default=False) postdate = db.Column(db.DateTime) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id'), nullable=True) parent_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) + private = db.Column(db.Boolean, default=False) replies = db.relationship("Post", backref=db.backref('parent_post', remote_side='Post.id')) # Simple in-memory cache for human-readable time labels. lastcheck = None savedresponse = None - def __init__(self, title=None, content=None, postdate=None, upload_file=None): + def __init__(self, title=None, content=None, postdate=None, upload_file=None, private=False): self.title = title self.content = content self.postdate = postdate self.upload_file = upload_file + self.private = private def get_time_string(self): # Recalculate every 30 seconds to avoid inaccurate time labels diff --git a/forum/post_routes.py b/forum/post_routes.py index 82ecd92..e0732f9 100644 --- a/forum/post_routes.py +++ b/forum/post_routes.py @@ -30,6 +30,8 @@ def viewpost(): post = Post.query.filter(Post.id == postid).first() if not post: return error("That post does not exist!") + if post.private and (not current_user.is_authenticated or post.user_id != current_user.id): + return error("This post is private.") subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) # Newest replies appear first for easier reading. comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) @@ -73,12 +75,13 @@ def action_post(): retry = True if retry: return render_template("createpost.html", subforum=subforum, errors=errors) + private = 'private' in request.form file = request.files.get('upload_file') filename = None if file and file.filename: filename = secure_filename(file.filename) file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) - post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename) + post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename, private=private) subforum.posts.append(post) user.posts.append(post) db.session.commit() diff --git a/forum/routes.py b/forum/routes.py index 9d4ce90..149527e 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -65,18 +65,18 @@ def action_createaccount(): return redirect("/") -# @rt.route('/subforum') -# def subforum(): -# # Show one subforum, its posts, and its child subforums. -# subforum_id = int(request.args.get("sub")) -# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() -# if not subforum: -# return error("That subforum does not exist!") -# posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) -# subforumpath = subforum.path or generateLinkPath(subforum.id) - -# subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() -# return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) +@rt.route('/subforum') +def subforum(): + # Show one subforum, its posts, and its child subforums. + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) + subforumpath = subforum.path or generateLinkPath(subforum.id) + + subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() + return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) @rt.route('/loginform') def loginform(): diff --git a/forum/templates/createpost.html b/forum/templates/createpost.html index a706c4d..8d9715c 100644 --- a/forum/templates/createpost.html +++ b/forum/templates/createpost.html @@ -16,6 +16,7 @@

+
diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 66b60a6..aa2e937 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -5,6 +5,7 @@
{{post.title}} + {% if post.private %}๐Ÿ”’ Private{% endif %}
{{ post.user.username }} From 433d4ad1e9265e000538a84a74c4788caeb76850 Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 18:57:37 -0400 Subject: [PATCH 35/68] redid publicity and fixed file insertion errors --- config.py | 2 +- forum/post.py | 1 - forum/routes.py | 5 ++++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index 8881ebe..a361fcf 100644 --- a/config.py +++ b/config.py @@ -11,7 +11,7 @@ class Config: # General Config SECRET_KEY = 'kristofer' FLASK_APP = 'forum.app' - UPLOAD_FOLDER = 'forum/static/uploads' + UPLOAD_FOLDER = path.join(basedir, 'forum', 'static', 'uploads') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'webm'} # Database diff --git a/forum/post.py b/forum/post.py index 3d15ad8..65ba741 100644 --- a/forum/post.py +++ b/forum/post.py @@ -13,7 +13,6 @@ class Post(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id')) subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id'), nullable=True) parent_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) - private = db.Column(db.Boolean, default=False) replies = db.relationship("Post", backref=db.backref('parent_post', remote_side='Post.id')) # Simple in-memory cache for human-readable time labels. diff --git a/forum/routes.py b/forum/routes.py index 149527e..0fa6347 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -72,7 +72,10 @@ def subforum(): subforum = Subforum.query.filter(Subforum.id == subforum_id).first() if not subforum: return error("That subforum does not exist!") - posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) + posts = Post.query.filter( + Post.subforum_id == subforum_id, + (Post.private == False) | (Post.user_id == current_user.id if current_user.is_authenticated else False) + ).order_by(Post.id.desc()).limit(50) subforumpath = subforum.path or generateLinkPath(subforum.id) subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() From 79019f6e92ea099f7f5cc578c856fc67eef909f8 Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 19:18:36 -0400 Subject: [PATCH 36/68] Final fixes with post --- forum/models.py | 115 +------------------------------------------ forum/post_routes.py | 4 +- forum/routes.py | 5 +- forum/subforum.py | 6 ++- 4 files changed, 12 insertions(+), 118 deletions(-) diff --git a/forum/models.py b/forum/models.py index e618528..3eed9cd 100644 --- a/forum/models.py +++ b/forum/models.py @@ -27,121 +27,10 @@ def __init__(self, email, username, password): def check_password(self, password): # Compare a password guess against the stored hash. return check_password_hash(self.password_hash, password) - -class Subforum(db.Model): - # Represent a forum category and its optional child subforums. - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text, unique=True) - description = db.Column(db.Text) - subforums = db.relationship("Subforum") - parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - posts = db.relationship("Post", backref="subforum") - path = None - hidden = db.Column(db.Boolean, default=False) - - def __init__(self, title, description): - self.title = title - self.description = description - -# Post is defined in post.py; imported here after db is ready to avoid -# circular imports while keeping Post in its own module. -from .post import Post # noqa: E402 -class Post(db.Model): - # Store one forum post and link it to a user and subforum. - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text) - content = db.Column(db.Text) - comments = db.relationship("Comment", backref="post") - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - postdate = db.Column(db.DateTime) - - # Simple in-memory cache for human-readable time labels. - lastcheck = None - savedresponce = None - - def __init__(self, title, content, postdate): - self.title = title - self.content = content - self.postdate = postdate - - def get_time_string(self): - # Only recalculate the label every 30 seconds. - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - - return self.savedresponce - -# class Subforum(db.Model): -# # Represent a forum category and its optional child subforums. -# id = db.Column(db.Integer, primary_key=True) -# title = db.Column(db.Text, unique=True) -# description = db.Column(db.Text) -# subforums = db.relationship("Subforum") -# parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) -# posts = db.relationship("Post", backref="subforum") -# path = None -# hidden = db.Column(db.Boolean, default=False) - -# def __init__(self, title, description): -# self.title = title -# self.description = description - -class Comment(db.Model): - # Store a comment attached to a post and authored by a user. - id = db.Column(db.Integer, primary_key=True) - content = db.Column(db.Text) - postdate = db.Column(db.DateTime) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - post_id = db.Column(db.Integer, db.ForeignKey("post.id")) - - lastcheck = None - savedresponce = None - - def __init__(self, content, postdate): - self.content = content - self.postdate = postdate - - def get_time_string(self): - # Only recalculate the label every 30 seconds. - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - return self.savedresponce +# Post is defined in post.py and imported by routes directly. +# Subforum is defined in subforum.py. def error(errormessage): return "" + errormessage + "" diff --git a/forum/post_routes.py b/forum/post_routes.py index e0732f9..3c19982 100644 --- a/forum/post_routes.py +++ b/forum/post_routes.py @@ -2,7 +2,9 @@ from flask_login import current_user, login_user, logout_user from flask_login.utils import login_required import datetime -from .models import User, Post, Subforum, valid_content, valid_title, db, generateLinkPath, error +from .models import User, valid_content, valid_title, db, error +from .post import Post +from .subforum import Subforum, generateLinkPath from .user import username_taken, email_taken, valid_username from .routes import rt import os diff --git a/forum/routes.py b/forum/routes.py index 0fa6347..1f1e844 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,9 +3,10 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, valid_content, valid_title, db, error +from .models import User, valid_content, valid_title, db, error +from .post import Post +from .subforum import Subforum, generateLinkPath from .user import username_taken, email_taken, valid_username -from .subforum import Subforum, db, generateLinkPath # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. diff --git a/forum/subforum.py b/forum/subforum.py index fa6145b..3fa2621 100644 --- a/forum/subforum.py +++ b/forum/subforum.py @@ -1,4 +1,5 @@ -from .models import db, Post, valid_content, valid_title, error +from .models import db, valid_content, valid_title, error +from .post import Post class Subforum(db.Model): # Represent a forum category and its optional child subforums. @@ -46,7 +47,8 @@ def valid_content(content): from flask import Blueprint, render_template, request, redirect from flask_login import current_user, login_required from flask_login.utils import login_required -from .models import Post, valid_content, valid_title +from .models import valid_content, valid_title +from .post import Post # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. From 9ffc31e5b5f5f4368880d1669ae065ce8103d60b Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 10:19:52 -0400 Subject: [PATCH 37/68] implemented some copoilt recs --- forum/routes.py | 30 +++++++++++++++--------------- forum/subforum.py | 9 ++++++--- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/forum/routes.py b/forum/routes.py index 1f1e844..a0cffb4 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -66,21 +66,21 @@ def action_createaccount(): return redirect("/") -@rt.route('/subforum') -def subforum(): - # Show one subforum, its posts, and its child subforums. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return error("That subforum does not exist!") - posts = Post.query.filter( - Post.subforum_id == subforum_id, - (Post.private == False) | (Post.user_id == current_user.id if current_user.is_authenticated else False) - ).order_by(Post.id.desc()).limit(50) - subforumpath = subforum.path or generateLinkPath(subforum.id) - - subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() - return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) +# @rt.route('/subforum') +# def subforum(): +# # Show one subforum, its posts, and its child subforums. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return error("That subforum does not exist!") +# posts = Post.query.filter( +# Post.subforum_id == subforum_id, +# (Post.private == False) | (Post.user_id == current_user.id if current_user.is_authenticated else False) +# ).order_by(Post.id.desc()).limit(50) +# subforumpath = subforum.path or generateLinkPath(subforum.id) + +# subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() +# return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) @rt.route('/loginform') def loginform(): diff --git a/forum/subforum.py b/forum/subforum.py index 3fa2621..4cc2fbb 100644 --- a/forum/subforum.py +++ b/forum/subforum.py @@ -57,11 +57,14 @@ def valid_content(content): @subforum_rt.route('/subforum') def subforum(): # Show one subforum, its posts, and its child subforums. - subforum_id = int(request.args.get("sub")) + subforum_id = request.args.get("sub", type=int) subforum = Subforum.query.filter(Subforum.id == subforum_id).first() if not subforum: return error("That subforum does not exist!") - posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) + posts = Post.query.filter( + Post.subforum_id == subforum_id, + (Post.private == False) | (Post.user_id == current_user.id if current_user.is_authenticated else False) + ).order_by(Post.id.desc()).limit(50) subforumpath = subforum.path or generateLinkPath(subforum.id) subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() @@ -128,7 +131,7 @@ def delete_subforum(): if not current_user.admin: return Subforum.error("Only administrators can delete subforums!") - subforum_id = int(request.form.get('subforum_id')) + subforum_id = request.form.get('subforum_id', type=int) subforum = Subforum.query.get(subforum_id) if not subforum: From e3225c0fd212b9b5e205fbb72bea699233f4ec23 Mon Sep 17 00:00:00 2001 From: nate Date: Fri, 10 Apr 2026 13:42:58 -0400 Subject: [PATCH 38/68] Post.py now houses its routes --- forum/__init__.py | 3 +- forum/post.py | 91 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index 912d983..4fa9d1e 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,6 +1,6 @@ from flask import Flask from .routes import rt -from .post_routes import rt +from .post import post_rt from .subforum import subforum_rt def create_app(): @@ -13,6 +13,7 @@ def create_app(): # etc # Register the main routes blueprint. app.register_blueprint(rt) + app.register_blueprint(post_rt) app.register_blueprint(subforum_rt) # Set globals from .models import db diff --git a/forum/post.py b/forum/post.py index 65ba741..74edc47 100644 --- a/forum/post.py +++ b/forum/post.py @@ -1,5 +1,13 @@ import datetime -from .models import db +import os +from flask import Blueprint, render_template, request, redirect, url_for, current_app, send_from_directory +from flask_login import current_user +from flask_login.utils import login_required +from werkzeug.utils import secure_filename +from .models import db, User, valid_content, valid_title, error + +post_rt = Blueprint('post_routes', __name__, template_folder='templates') + class Post(db.Model): # Store a forum post or a reply. Top-level posts have parent_id=None; @@ -27,7 +35,7 @@ def __init__(self, title=None, content=None, postdate=None, upload_file=None, pr self.private = private def get_time_string(self): - # Recalculate every 30 seconds to avoid inaccurate time labels + # Recalculate every 30 seconds to avoid inaccurate time labels now = datetime.datetime.now() if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: self.lastcheck = now @@ -47,3 +55,82 @@ def get_time_string(self): else: self.savedresponse = "Just a moment ago!" return self.savedresponse + + +# Routes are defined below after Post so they can reference it directly. +# Import Subforum here (after Post is defined) to avoid circular imports. +from .subforum import Subforum, generateLinkPath # noqa: E402 + + +@post_rt.route('/uploads/') +def uploaded_file(filename): + return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename) + +@post_rt.route('/addpost') +@login_required +def addpost(): + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + return render_template("createpost.html", subforum=subforum) + +@post_rt.route('/viewpost') +def viewpost(): + postid = int(request.args.get("post")) + post = Post.query.filter(Post.id == postid).first() + if not post: + return error("That post does not exist!") + if post.private and (not current_user.is_authenticated or post.user_id != current_user.id): + return error("This post is private.") + subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) + comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) + return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + +@post_rt.route('/action_comment', methods=['POST', 'GET']) +@login_required +def comment(): + post_id = int(request.args.get("post")) + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + content = request.form['content'] + reply = Post(content=content, postdate=datetime.datetime.now()) + reply.parent_id = post_id + current_user.posts.append(reply) + db.session.commit() + return redirect("/viewpost?post=" + str(post_id)) + +@post_rt.route('/action_post', methods=['POST']) +@login_required +def action_post(): + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return redirect(url_for("routes.index")) + + user = current_user + title = request.form['title'] + content = request.form['content'] + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(content): + errors.append("Post must be between 10 and 5000 characters long!") + retry = True + if retry: + return render_template("createpost.html", subforum=subforum, errors=errors) + private = 'private' in request.form + file = request.files.get('upload_file') + filename = None + if file and file.filename: + filename = secure_filename(file.filename) + file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) + post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename, private=private) + subforum.posts.append(post) + user.posts.append(post) + db.session.commit() + return redirect("/viewpost?post=" + str(post.id)) + From 4abbfcf1f0abec0bfc5265f9e96365fab3b98a12 Mon Sep 17 00:00:00 2001 From: nate Date: Fri, 10 Apr 2026 13:44:54 -0400 Subject: [PATCH 39/68] post_routes.begone --- forum/post_routes.py | 92 -------------------------------------------- 1 file changed, 92 deletions(-) delete mode 100644 forum/post_routes.py diff --git a/forum/post_routes.py b/forum/post_routes.py deleted file mode 100644 index 27bed37..0000000 --- a/forum/post_routes.py +++ /dev/null @@ -1,92 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, current_app, send_from_directory -from flask_login import current_user, login_user, logout_user -from flask_login.utils import login_required -import datetime -from .models import User, valid_content, valid_title, db, error -from .post import Post -from .subforum import Subforum, generateLinkPath -from .user import username_taken, email_taken, valid_username -from .routes import rt -import os -from werkzeug.utils import secure_filename -from .post import Post -from .subforum import Subforum, generateLinkPath - - -@rt.route('/uploads/') -def uploaded_file(filename): - return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename) - -@rt.route('/addpost') -@login_required -def addpost(): - # Show the new post form for the selected subforum. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return error("That subforum does not exist!") - return render_template("createpost.html", subforum=subforum) - -@rt.route('/viewpost') -def viewpost(): - # Show one post and its comments. - postid = int(request.args.get("post")) - post = Post.query.filter(Post.id == postid).first() - if not post: - return error("That post does not exist!") - if post.private and (not current_user.is_authenticated or post.user_id != current_user.id): - return error("This post is private.") - subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) - # Newest replies appear first for easier reading. - comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) - return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) - -@rt.route('/action_comment', methods=['POST', 'GET']) -@login_required -def comment(): - # Create a comment, attach it to the user and post, then save it. - post_id = int(request.args.get("post")) - post = Post.query.filter(Post.id == post_id).first() - if not post: - return error("That post does not exist!") - content = request.form['content'] - postdate = datetime.datetime.now() - reply = Post(content=content, postdate=postdate) - reply.parent_id = post_id - current_user.posts.append(reply) - db.session.commit() - return redirect("/viewpost?post=" + str(post_id)) - -@rt.route('/action_post', methods=['POST']) -@login_required -def action_post(): - # Validate a new post before saving it. - subforum_id = int(request.args.get("sub")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return redirect(url_for("index")) - - user = current_user - title = request.form['title'] - content = request.form['content'] - errors = [] - retry = False - if not valid_title(title): - errors.append("Title must be between 4 and 140 characters long!") - retry = True - if not valid_content(content): - errors.append("Post must be between 10 and 5000 characters long!") - retry = True - if retry: - return render_template("createpost.html", subforum=subforum, errors=errors) - private = 'private' in request.form - file = request.files.get('upload_file') - filename = None - if file and file.filename: - filename = secure_filename(file.filename) - file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) - post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename, private=private) - subforum.posts.append(post) - user.posts.append(post) - db.session.commit() - return redirect("/viewpost?post=" + str(post.id)) From 7de88bfc5f22a97158ec488cc9d28b7b738c62b8 Mon Sep 17 00:00:00 2001 From: nate Date: Fri, 10 Apr 2026 16:55:15 -0400 Subject: [PATCH 40/68] Post images having a stroke fixed once more (I pray) --- forum/post.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/forum/post.py b/forum/post.py index 74edc47..3a9502b 100644 --- a/forum/post.py +++ b/forum/post.py @@ -127,7 +127,9 @@ def action_post(): filename = None if file and file.filename: filename = secure_filename(file.filename) - file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) + upload_folder = current_app.config['UPLOAD_FOLDER'] + os.makedirs(upload_folder, exist_ok=True) + file.save(os.path.join(upload_folder, filename)) post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename, private=private) subforum.posts.append(post) user.posts.append(post) From 102aa0f1f8adc547674a6225b0f50aea55dddcfc Mon Sep 17 00:00:00 2001 From: nate Date: Fri, 10 Apr 2026 17:31:15 -0400 Subject: [PATCH 41/68] gitignore uploads on commits and pushes --- .gitignore | 4 ++++ forum/static/uploads/.gitkeep | 0 2 files changed, 4 insertions(+) create mode 100644 forum/static/uploads/.gitkeep diff --git a/.gitignore b/.gitignore index 3729cd4..65fdf95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ venv/ .vscode +# Uploaded user files โ€” kept locally only, not committed +forum/static/uploads/* +!forum/static/uploads/.gitkeep + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/forum/static/uploads/.gitkeep b/forum/static/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 From 537689d9d4f760b550f39ffb5e10966326c6feef Mon Sep 17 00:00:00 2001 From: nate Date: Sat, 11 Apr 2026 13:46:08 -0400 Subject: [PATCH 42/68] Deletepost route and button --- forum/post.py | 13 +++++++++++++ forum/templates/viewpost.html | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/forum/post.py b/forum/post.py index 3a9502b..26c518b 100644 --- a/forum/post.py +++ b/forum/post.py @@ -136,3 +136,16 @@ def action_post(): db.session.commit() return redirect("/viewpost?post=" + str(post.id)) +@post_rt.route('/action_delete_post', methods=['POST']) +@login_required +def action_delete_post(): + post_id = int(request.args.get("post")) + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + if post.user_id != current_user.id and not current_user.admin: + return error("You do not have permission to delete this post.") + Post.query.filter(Post.parent_id == post_id).delete() + db.session.delete(post) + db.session.commit() + return redirect("/subforum?sub=" + str(post.subforum_id)) \ No newline at end of file diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 0da7d6a..93fe697 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -26,7 +26,14 @@ {% endif %} {% endif %} - + + {% if current_user.is_authenticated and (current_user.id == post.user_id or current_user.admin) %} +
+ + +
+ {% endif %}
From c53fd3b6e1fbec1027b0d827136b54a783c6112d Mon Sep 17 00:00:00 2001 From: david Date: Sat, 11 Apr 2026 14:41:54 -0400 Subject: [PATCH 43/68] Fixed indent error non type error --- forum/post.py | 11 ++++++++--- requirements.txt | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/forum/post.py b/forum/post.py index 26c518b..3ec45d4 100644 --- a/forum/post.py +++ b/forum/post.py @@ -3,8 +3,10 @@ from flask import Blueprint, render_template, request, redirect, url_for, current_app, send_from_directory from flask_login import current_user from flask_login.utils import login_required +from httpx import post from werkzeug.utils import secure_filename from .models import db, User, valid_content, valid_title, error +import httpx post_rt = Blueprint('post_routes', __name__, template_folder='templates') @@ -139,13 +141,16 @@ def action_post(): @post_rt.route('/action_delete_post', methods=['POST']) @login_required def action_delete_post(): - post_id = int(request.args.get("post")) + post_param = request.args.get("post") + if not post_param: + return error("No post specified!") + post_id = int(post_param) post = Post.query.filter(Post.id == post_id).first() if not post: return error("That post does not exist!") if post.user_id != current_user.id and not current_user.admin: return error("You do not have permission to delete this post.") Post.query.filter(Post.parent_id == post_id).delete() - db.session.delete(post) - db.session.commit() + db.session.delete(post) + db.session.commit() return redirect("/subforum?sub=" + str(post.subforum_id)) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 57494aa..f748ec5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ Flask Flask-Login Flask-SQLAlchemy gunicorn +httpx itsdangerous Jinja2 MarkupSafe From 6e000b05b227301d5c8538e18399cc7dceee9902 Mon Sep 17 00:00:00 2001 From: nate Date: Sat, 11 Apr 2026 14:51:10 -0400 Subject: [PATCH 44/68] Deletepost error fixes --- forum/post.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/forum/post.py b/forum/post.py index 3ec45d4..d7267b8 100644 --- a/forum/post.py +++ b/forum/post.py @@ -141,16 +141,13 @@ def action_post(): @post_rt.route('/action_delete_post', methods=['POST']) @login_required def action_delete_post(): - post_param = request.args.get("post") - if not post_param: - return error("No post specified!") - post_id = int(post_param) - post = Post.query.filter(Post.id == post_id).first() - if not post: - return error("That post does not exist!") - if post.user_id != current_user.id and not current_user.admin: - return error("You do not have permission to delete this post.") - Post.query.filter(Post.parent_id == post_id).delete() - db.session.delete(post) - db.session.commit() - return redirect("/subforum?sub=" + str(post.subforum_id)) \ No newline at end of file + post_id = int(request.form['post_id']) + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + if post.user_id != current_user.id and not current_user.admin: + return error("You do not have permission to delete this post.") + Post.query.filter(Post.parent_id == post_id).delete() + db.session.delete(post) + db.session.commit() + return redirect("/subforum?sub=" + str(post.subforum_id)) \ No newline at end of file From 1bb51477892c7ce2d229e138c24d4a85075b2b46 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 11:48:22 -0400 Subject: [PATCH 45/68] merge conflicts --- forum/user.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/forum/user.py b/forum/user.py index 8765da1..b1716fa 100644 --- a/forum/user.py +++ b/forum/user.py @@ -1,3 +1,32 @@ +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import UserMixin +import datetime + +# Shared SQLAlchemy object used by the app factory and all models. +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + +# Database models +class User(UserMixin, db.Model): + # Store account information and ownership of posts/comments. + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) + admin = db.Column(db.Boolean, default=False) + posts = db.relationship("Post", backref="user") + + def __init__(self, email, username, password): + # Save the hashed password instead of the plain text password. + self.email = email + self.username = username + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + # Compare a password guess against the stored hash. + return check_password_hash(self.password_hash, password) + from .models import User @@ -23,3 +52,63 @@ def username_taken(username): return User.query.filter(User.username == username).first() def email_taken(email): return User.query.filter(User.email == email).first() + + +########################################################################## +import hashlib +import hmac +import secrets + + +class User: + def __init__(self, id, username, password, email, + is_admin=False, permissions=None, privacy="public"): + + # --- Core Fields --- + self.id = id # Primary Key + self.username = username + self.password_hash = self._hash_password(password) + self.email = email + + # --- Admin / Settings --- + self.is_admin = is_admin + self.permissions = permissions if permissions else [] + self.privacy = privacy + + # --- Relationships (instead of FK fields) --- + self.post_fk = [] # list of post IDs + self.comments_fk = [] # list of comment IDs + + # ------------------------- + # Helper Methods + # ------------------------- + + @staticmethod + def _hash_password(password): + salt = secrets.token_hex(16) + digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return f"{salt}${digest}" + + def check_password(self, password): + salt, stored_digest = self.password_hash.split("$", 1) + digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return hmac.compare_digest(stored_digest, digest) + + def add_post(self, post_id): + self.post_fk.append(post_id) + + def add_comment(self, comment_id): + self.comments_fk.append(comment_id) + + def set_privacy(self, privacy): + self.privacy = privacy + + def add_permission(self, permission): + if permission not in self.permissions: + self.permissions.append(permission) + + def is_allowed(self, permission): + return self.is_admin or permission in self.permissions + + def __repr__(self): + return f"" From b9d5a183f5f4b2e21337a45be3741412dad20029 Mon Sep 17 00:00:00 2001 From: emoyer Date: Fri, 10 Apr 2026 13:48:29 -0400 Subject: [PATCH 46/68] Condensed MySQL migration --- config.py | 335 ++---------------------------------------------- forum/app.py | 49 ++++++- forum/ignore.py | 146 +++++++++++++++++++++ 3 files changed, 202 insertions(+), 328 deletions(-) create mode 100644 forum/ignore.py diff --git a/config.py b/config.py index b8da4e4..13a490d 100644 --- a/config.py +++ b/config.py @@ -1,347 +1,28 @@ -""" -Flask configuration variables. -This file stores all the settings Flask needs to run the app, including -database connection details. It reads settings from environment variables, -but has safe defaults if those variables aren't set. - -This file also does a small amount of startup work for MySQL so the app can -try to prepare its database automatically before Flask builds tables. -""" +"""Flask configuration and MySQL bootstrap helpers.""" + from os import environ, path + import pymysql basedir = path.abspath(path.dirname(__file__)) -# If we ever want to load a local .env file again, this is the place to do it. -# It is left commented out because the project currently relies on environment -# variables provided by the launcher script instead of a separate .env file. -# from dotenv import load_dotenv -# load_dotenv(path.join(basedir, '.env')) - - -def try_admin_connection(): - """ - Try to connect to MySQL as the local admin user. - - Why this exists: - - The app needs a way to create the database and app user on first run. - - On some machines MySQL allows local admin access through a socket. - - On other machines the root account may need a password. - - What it returns: - - (connection, method) if one of the connection attempts worked. - - (None, None) if every attempt failed. - - The function tries the most common local MySQL access paths first and only - falls back to a root password if the environment provides one. - """ - # Common socket locations on macOS. - # A socket connection is the most convenient option when MySQL is installed - # locally because it may work without needing a password at all. - socket_paths = [ - "/tmp/mysql.sock", - "/var/run/mysql/mysql.sock", - "/usr/local/var/run/mysql.sock" - ] - - # Try socket connections first. - # We stop at the first one that works because the exact socket path can - # vary depending on how MySQL was installed. - for socket_path in socket_paths: - try: - admin_conn = pymysql.connect( - user="root", - unix_socket=socket_path, - charset='utf8mb4' - ) - return (admin_conn, f"socket ({socket_path})") - except Exception: - # If this socket does not exist or the server rejects the connection, - # we just move on to the next possible path. - continue - - # Try plain TCP/IP next. - # This is the simplest network-style connection to local MySQL. - try: - admin_conn = pymysql.connect( - host="127.0.0.1", - port=3306, - user="root", - password="", - charset='utf8mb4' - ) - return (admin_conn, "TCP/IP (no password)") - except Exception: - pass - - # If the machine has a root password, allow the launcher to supply it. - # This keeps the code flexible for teams with different MySQL setups. - root_password = environ.get("MYSQL_ROOT_PASSWORD", "") - if root_password: - try: - admin_conn = pymysql.connect( - host="127.0.0.1", - port=3306, - user="root", - password=root_password, - charset='utf8mb4' - ) - return (admin_conn, "TCP/IP (with password)") - except Exception: - # A password was provided, but it still did not work. - # The caller will handle the failure message. - pass - - # If we get here, MySQL admin access is not available through any of the - # local methods we tried. - return (None, None) - - -def setup_database_and_user(): - """ - Ensure the database and app user exist in MySQL. - - What this does: - - creates the database if it does not exist - - creates the app user if it does not exist - - grants the app user access to the database - - Why it is here: - - A teammate should be able to run the app without manually preparing MySQL - every time. - - This is best-effort bootstrapping, not a replacement for proper database - administration in production. - - Important detail: - - This function uses a local MySQL admin connection only for setup tasks. - - The actual app still connects as the normal application user. - """ - # These values define the default app database identity. - # We keep them simple and consistent so the launcher and config agree. - app_user = "zipchat_app" - app_password = "password" - db_host = "127.0.0.1" - db_name = "ZipChat" - - # Try to connect as the local MySQL admin user. - # If this fails, we can still continue later and let the normal app - # connection path explain what is missing. - admin_conn, method = try_admin_connection() - - if admin_conn is None: - # We could not obtain admin access, so we cannot auto-create anything. - # The printed instructions are meant to tell a teammate exactly what to - # do next without having to inspect the code. - print(f"\nโš  Could not connect to MySQL as root") - print(f" Socket connection not available (fresh installs should have this)") - print(f" TCP/IP connection with no password didn't work either") - print(f"\n To fix:") - print(f" A) If you just installed MySQL, try: brew services restart mysql") - print(f" B) If you set a root password, provide it for setup:") - print(f" export MYSQL_ROOT_PASSWORD='your_root_password'") - print(f" bash ./run.sh") - print(f" C) Or manually create the database and user:") - print(f" mysql -u root -p") - print(f" CREATE DATABASE {db_name};") - print(f" CREATE USER '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';") - print(f" GRANT ALL PRIVILEGES ON {db_name}.* TO '{app_user}'@'{db_host}';") - print(f" FLUSH PRIVILEGES;") - print(f" EXIT;\n") - return False - - try: - print(f"โœ“ Connected to MySQL as root via {method}") - - with admin_conn.cursor() as cursor: - # Create the database if it does not already exist. - # Backticks around the name make the SQL a little safer if the - # database name ever contains special characters. - cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;") - print(f" โœ“ Database '{db_name}' ready") - - # Create the application user. - # This is the user the Flask app itself will use during normal - # runtime, not the root/admin account. - try: - cursor.execute(f"CREATE USER IF NOT EXISTS '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';") - print(f" โœ“ App user '{app_user}' created") - except pymysql.err.OperationalError as e: - if "already exists" in str(e): - print(f" โœ“ App user '{app_user}' already exists") - else: - raise - - # Grant the app user permission to work only with this database. - # This keeps the account scoped to the project instead of giving it - # access to every database on the server. - cursor.execute(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{app_user}'@'{db_host}';") - cursor.execute("FLUSH PRIVILEGES;") - print(f" โœ“ Permissions granted") - - admin_conn.commit() - admin_conn.close() - print(f"โœ“ MySQL setup complete\n") - return True - - except Exception as e: - # If anything unexpected happens during setup, print the exception so a - # teammate can see what went wrong instead of getting a silent failure. - print(f"โš  Error during setup: {e}\n") - admin_conn.close() - return False - - -def check_mysql_connection(): - """ - Verify the app can connect using the configured app credentials. - - This is the real runtime check for the Flask app. - If this succeeds, the database settings are good enough for normal use. - If it fails, we print a human-friendly explanation of the problem so the - user does not have to decode a long stack trace first. - """ - # Read the values the actual Flask app will use. - # These can still be overridden from the environment when needed. - app_user = environ.get("DB_USER", "zipchat_app") - app_password = environ.get("DB_PASSWORD", "password") - db_host = environ.get("DB_HOST", "127.0.0.1") - db_port = int(environ.get("DB_PORT", "3306")) - db_name = environ.get("DB_NAME", "ZipChat") - - try: - # This is the same style of connection SQLAlchemy will use when it - # creates tables and runs ORM queries. - connection = pymysql.connect( - host=db_host, - port=db_port, - user=app_user, - password=app_password, - database=db_name, - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor - ) - connection.close() - print(f"โœ“ MySQL ready: {app_user}@{db_name}\n") - return True - - except pymysql.err.OperationalError as e: - # PyMySQL returns MySQL error codes, so we can match common failures and - # print a specific fix for each one. - error_code = e.args[0] if e.args else None - - if error_code == 2003: - print(f"โŒ MySQL not running on {db_host}:{db_port}") - print(f"Start it: brew services start mysql\n") - return False - - elif error_code == 1045: - print(f"โŒ Cannot login as '{app_user}'") - print(f"Either the user doesn't exist or the password is wrong.") - print(f"Make sure setup completed successfully above.\n") - return False - - elif error_code == 1049: - print(f"โŒ Database '{db_name}' doesn't exist") - print(f"Make sure setup completed successfully above.\n") - return False - - else: - # If we do not recognize the error code, still show the raw error so - # the team has something concrete to debug. - print(f"โŒ MySQL error ({error_code}): {e}\n") - return False - - -def check_mysql_and_setup(): - """ - Run the MySQL bootstrap sequence during app startup. - - The order matters: - 1. Try to create the database and app user. - 2. Verify that the app can connect with the runtime credentials. - - This function is intentionally small because it is called when the config - module is imported, so it should be easy to read and easy to debug. - """ - print("Checking MySQL setup...") - setup_database_and_user() - check_mysql_connection() class Config: - """ - Configuration class that stores all settings Flask needs. - This includes database connection info, secret keys, and SQLAlchemy options. - - Settings can be customized by setting environment variables: - - DB_USER: MySQL username (default: "zipchat_app") - - DB_PASSWORD: MySQL password (default: "password") - - DB_HOST: MySQL server address (default: "127.0.0.1") - - DB_PORT: MySQL server port (default: "3306") - - DB_NAME: Database name (default: "ZipChat") + """Configuration values consumed by Flask and Flask-SQLAlchemy.""" - The class is used by Flask-SQLAlchemy and the app factory, so keeping the - settings here makes startup predictable and easy to trace. - """ - - # ========== GENERAL SETTINGS ========== - # SECRET_KEY signs Flask sessions and CSRF tokens. - # In a real deployment this should come from the environment, not be hard- - # coded in source control. - SECRET_KEY = 'kristofer' - - # Flask can use this to know which module contains the app entry point. - FLASK_APP = 'forum.app' - UPLOAD_FOLDER = path.join(basedir, 'forum', 'static', 'uploads') - ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'webm'} + SECRET_KEY = environ.get("SECRET_KEY", "kristofer") + FLASK_APP = "forum.app" + UPLOAD_FOLDER = path.join(basedir, "forum", "static", "uploads") + ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "mp4", "mov", "webm"} - - # ========== DATABASE SETTINGS ========== - # These values describe the database connection the application will use. - # Each one can be overridden from the shell, which keeps local development - # simple while still allowing other environments to customize the values. - - # Username for the MySQL account the app uses at runtime. DB_USER = environ.get("DB_USER", "zipchat_app") - - # Password for the runtime MySQL account. - # The launcher defaults to "password" so a new clone can start without - # asking the user to configure secrets first. DB_PASSWORD = environ.get("DB_PASSWORD", "password") - - # Hostname or IP address where MySQL is running. DB_HOST = environ.get("DB_HOST", "127.0.0.1") - - # TCP port MySQL listens on. DB_PORT = environ.get("DB_PORT", "3306") - - # Name of the database the app reads and writes to. DB_NAME = environ.get("DB_NAME", "ZipChat") - # SQLAlchemy wants a single database URL instead of separate pieces. - # The format below means: - # mysql+pymysql -> use MySQL with the PyMySQL driver - # username -> the runtime MySQL user - # password -> that user's password - # host:port -> where MySQL is listening - # database -> which schema to use SQLALCHEMY_DATABASE_URI = ( f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" ) - - # When True, SQLAlchemy prints every SQL statement it sends to MySQL. - # This is useful for debugging but noisy for normal use, so we keep it off. SQLALCHEMY_ECHO = False - - # This is usually False because it avoids extra bookkeeping overhead. - # Keeping it off makes the app lighter and is the normal Flask-SQLAlchemy - # setting for most projects. SQLALCHEMY_TRACK_MODIFICATIONS = False - - -# Run the MySQL setup check as soon as config.py is imported. -# That means the app gets a chance to prepare MySQL before Flask starts using -# the database for table creation and queries. -try: - check_mysql_and_setup() -except Exception as e: - print(f"โš  Warning during MySQL setup: {e}") \ No newline at end of file diff --git a/forum/app.py b/forum/app.py index 1f747e2..9c5d894 100644 --- a/forum/app.py +++ b/forum/app.py @@ -7,8 +7,9 @@ from flask import render_template from flask_login import LoginManager +from sqlalchemy import inspect, text from .models import db, User -from .subforum import Subforum, db +from .subforum import Subforum from forum import create_app # Build the Flask app using the package factory. @@ -47,6 +48,51 @@ def add_subforum(title, description, parent=None, protected=False): db.session.commit() return sub + +def _sql_literal(value): + # Convert Python default values into SQL literals for ALTER TABLE statements. + if value is None: + return "NULL" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return str(value) + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + + +def ensure_model_schema_compatibility(): + # Keep existing MySQL tables compatible with current and future model columns. + inspector = inspect(db.engine) + existing_tables = set(inspector.get_table_names()) + + with db.engine.begin() as conn: + for mapper in db.Model.registry.mappers: + table = mapper.local_table + table_name = table.name + if table_name not in existing_tables: + continue + + existing_columns = {col["name"] for col in inspector.get_columns(table_name)} + for column in table.columns: + if column.name in existing_columns: + continue + if column.primary_key: + continue + + column_type = column.type.compile(dialect=db.engine.dialect) + nullability = "NULL" if column.nullable else "NOT NULL" + + default_sql = "" + if column.default is not None and getattr(column.default, "is_scalar", False): + default_sql = f" DEFAULT {_sql_literal(column.default.arg)}" + + sql = ( + f"ALTER TABLE `{table_name}` " + f"ADD COLUMN `{column.name}` {column_type} {nullability}{default_sql}" + ) + conn.execute(text(sql)) + # Flask-Login needs a loader so it can restore the current user from the session. login_manager = LoginManager() login_manager.init_app(app) @@ -59,6 +105,7 @@ def load_user(userid): with app.app_context(): # Create tables if needed, then seed the database the first time it runs. db.create_all() + ensure_model_schema_compatibility() if not Subforum.query.all(): init_site() diff --git a/forum/ignore.py b/forum/ignore.py new file mode 100644 index 0000000..3e40e86 --- /dev/null +++ b/forum/ignore.py @@ -0,0 +1,146 @@ +def try_admin_connection(): + """Try common local root login methods and return (connection, method).""" + socket_paths = [ + "/tmp/mysql.sock", + "/var/run/mysql/mysql.sock", + "/usr/local/var/run/mysql.sock", + ] + + for socket_path in socket_paths: + try: + admin_conn = pymysql.connect( + user="root", + unix_socket=socket_path, + charset="utf8mb4", + ) + return (admin_conn, f"socket ({socket_path})") + except Exception: + continue + + try: + admin_conn = pymysql.connect( + host="127.0.0.1", + port=3306, + user="root", + password="", + charset="utf8mb4", + ) + return (admin_conn, "TCP/IP (no password)") + except Exception: + pass + + root_password = environ.get("MYSQL_ROOT_PASSWORD", "") + if root_password: + try: + admin_conn = pymysql.connect( + host="127.0.0.1", + port=3306, + user="root", + password=root_password, + charset="utf8mb4", + ) + return (admin_conn, "TCP/IP (with password)") + except Exception: + pass + + return (None, None) + + +def setup_database_and_user(): + """Best-effort local setup for app database and app user.""" + app_user = "zipchat_app" + app_password = "password" + db_host = "127.0.0.1" + db_name = "ZipChat" + + admin_conn, method = try_admin_connection() + + if admin_conn is None: + print("\nCould not connect to MySQL as root") + print(" Socket connection not available") + print(" TCP/IP connection with no password did not work") + print("\n To fix:") + print(" A) brew services restart mysql") + print(" B) export MYSQL_ROOT_PASSWORD='your_root_password' && bash ./run.sh") + print(" C) Or create database and user manually") + print("") + return False + + try: + print(f"Connected to MySQL as root via {method}") + + with admin_conn.cursor() as cursor: + cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;") + + try: + cursor.execute( + f"CREATE USER IF NOT EXISTS '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';" + ) + except pymysql.err.OperationalError as err: + if "already exists" not in str(err): + raise + + cursor.execute( + f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{app_user}'@'{db_host}';" + ) + cursor.execute("FLUSH PRIVILEGES;") + + admin_conn.commit() + admin_conn.close() + print("MySQL setup complete\n") + return True + + except Exception as err: + print(f"Error during setup: {err}\n") + admin_conn.close() + return False + + +def check_mysql_connection(): + """Verify runtime app credentials can connect to MySQL.""" + app_user = environ.get("DB_USER", "zipchat_app") + app_password = environ.get("DB_PASSWORD", "password") + db_host = environ.get("DB_HOST", "127.0.0.1") + db_port = int(environ.get("DB_PORT", "3306")) + db_name = environ.get("DB_NAME", "ZipChat") + + try: + connection = pymysql.connect( + host=db_host, + port=db_port, + user=app_user, + password=app_password, + database=db_name, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + ) + connection.close() + print(f"MySQL ready: {app_user}@{db_name}\n") + return True + + except pymysql.err.OperationalError as err: + error_code = err.args[0] if err.args else None + + if error_code == 2003: + print(f"MySQL not running on {db_host}:{db_port}") + print("Start it: brew services start mysql\n") + return False + + if error_code == 1045: + print(f"Cannot login as '{app_user}'") + print("Either the user does not exist or the password is wrong.\n") + return False + + if error_code == 1049: + print(f"Database '{db_name}' does not exist\n") + return False + + print(f"MySQL error ({error_code}): {err}\n") + return False + + +def check_mysql_and_setup(): + """Run setup then verify runtime MySQL connectivity.""" + print("Checking MySQL setup...") + setup_database_and_user() + check_mysql_connection() \ No newline at end of file From 4154546d8cfbac6f254be7f81645dab21e9c51e0 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 13:51:23 -0400 Subject: [PATCH 47/68] deleted unused import and white space --- config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config.py b/config.py index 13a490d..a41c9e0 100644 --- a/config.py +++ b/config.py @@ -2,8 +2,6 @@ from os import environ, path -import pymysql - basedir = path.abspath(path.dirname(__file__)) @@ -25,4 +23,4 @@ class Config: f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" ) SQLALCHEMY_ECHO = False - SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file From cb4f1e77fe152bf2a88e37db765ebe31bc46fbb6 Mon Sep 17 00:00:00 2001 From: james Date: Fri, 10 Apr 2026 16:05:03 -0400 Subject: [PATCH 48/68] Reactions html moved to viewpost.html and deleted from Reactions.py, reactions relationship added to post.py; blueprint registration added to __init__.py --- forum/Reactions.py | 30 ++++++++++++------------------ forum/__init__.py | 2 ++ forum/post.py | 3 ++- forum/templates/viewpost.html | 13 +++++++++++++ 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/forum/Reactions.py b/forum/Reactions.py index c51dc15..69bd38f 100644 --- a/forum/Reactions.py +++ b/forum/Reactions.py @@ -1,4 +1,14 @@ -#Add to Posts.py eventually +import post.py +from flask import render_template, request, redirect, url_for +from flask_login import current_user, login_user, logout_user +from flask_login.utils import login_required +import datetime +from flask import Blueprint, render_template, request, redirect, url_for +from .models import User, Post, Comment, valid_content, valid_title, db, error +from .user import username_taken, email_taken, valid_username +from .subforum import Subforum, db, generateLinkPath + +rt_react = Blueprint('rt_react', __name__) class Reaction(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -6,7 +16,7 @@ class Reaction(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id')) post_id = db.Column(db.Integer, db.ForeignKey('post.id')) -@rt.route('/action_react', methods=['POST']) +@rt_react.route('/action_react', methods=['POST']) @login_required def action_react(): post_id = int(request.form['post_id']) @@ -21,19 +31,3 @@ def action_react(): db.session.add(r) db.session.commit() return redirect('/viewpost?post=' + str(post_id)) - -# Add to Post model so that post.reactions works -reactions = db.relationship('Reaction', backref='post') - -#Add in viewpost.html inside {% block body%} below the
of actual post, eventually -
- - {% for emoji, kind in [('๐Ÿ‘', 'up'), ('๐Ÿ‘Ž', 'down'), ('โค๏ธ', 'heart')] %} - {% set count = post.reactions | selectattr('kind', 'eq', kind) | list | length %} - {% set reacted = current_user.is_authenticated and post.reactions | selectattr('kind','eq',kind) | selectattr('user_id','eq',current_user.id) | list %} - - {% endfor %} -
- diff --git a/forum/__init__.py b/forum/__init__.py index 4fa9d1e..a8974e9 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,6 +2,7 @@ from .routes import rt from .post import post_rt from .subforum import subforum_rt +from .Reactions import rt_react def create_app(): """Construct the core application.""" @@ -15,6 +16,7 @@ def create_app(): app.register_blueprint(rt) app.register_blueprint(post_rt) app.register_blueprint(subforum_rt) + app.register_blueprint(rt_react) # Set globals from .models import db db.init_app(app) diff --git a/forum/post.py b/forum/post.py index d7267b8..56477b9 100644 --- a/forum/post.py +++ b/forum/post.py @@ -24,7 +24,8 @@ class Post(db.Model): subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id'), nullable=True) parent_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) replies = db.relationship("Post", backref=db.backref('parent_post', remote_side='Post.id')) - + reactions = db.relationship('Reaction', backref='post') + # Simple in-memory cache for human-readable time labels. lastcheck = None savedresponse = None diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 93fe697..639446f 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -25,6 +25,7 @@ {% else %} {% endif %} + {% endif %} {% endif %} {% if current_user.is_authenticated and (current_user.id == post.user_id or current_user.admin) %} @@ -36,6 +37,18 @@ {% endif %}
+ +
+ + {% for emoji, kind in [('๐Ÿ‘', 'up'), ('๐Ÿ‘Ž', 'down'), ('โค๏ธ', 'heart')] %} + {% set count = post.reactions | selectattr('kind', 'eq', kind) | list | length %} + {% set reacted = current_user.is_authenticated and post.reactions | selectattr('kind','eq',kind) | selectattr('user_id','eq',current_user.id) | list %} + + {% endfor %} +
+

From b9817f8f84f683cce9b666c20641d3010acb1814 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 16:09:12 -0400 Subject: [PATCH 49/68] merged reactions --- forum/Reactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum/Reactions.py b/forum/Reactions.py index 69bd38f..ad70974 100644 --- a/forum/Reactions.py +++ b/forum/Reactions.py @@ -1,4 +1,4 @@ -import post.py +#import post.py from flask import render_template, request, redirect, url_for from flask_login import current_user, login_user, logout_user from flask_login.utils import login_required From 10352d5c3fbe92432ad7ec9676311ae06e9a035c Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 16:15:11 -0400 Subject: [PATCH 50/68] merged post updates --- forum/Reactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum/Reactions.py b/forum/Reactions.py index ad70974..3943229 100644 --- a/forum/Reactions.py +++ b/forum/Reactions.py @@ -4,7 +4,7 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, Post, Comment, valid_content, valid_title, db, error +from .models import db from .user import username_taken, email_taken, valid_username from .subforum import Subforum, db, generateLinkPath From 616794a80986058dd21381cb10088718d2e3dcf8 Mon Sep 17 00:00:00 2001 From: james Date: Fri, 10 Apr 2026 17:21:21 -0400 Subject: [PATCH 51/68] Login prompt added to html file, unnecessary importing commands removed. --- forum/Reactions.py | 5 ++--- forum/templates/viewpost.html | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/forum/Reactions.py b/forum/Reactions.py index 3943229..0ee7fe9 100644 --- a/forum/Reactions.py +++ b/forum/Reactions.py @@ -1,9 +1,8 @@ #import post.py -from flask import render_template, request, redirect, url_for -from flask_login import current_user, login_user, logout_user +from flask import request, redirect, Blueprint, request, redirect +from flask_login import current_user from flask_login.utils import login_required import datetime -from flask import Blueprint, render_template, request, redirect, url_for from .models import db from .user import username_taken, email_taken, valid_username from .subforum import Subforum, db, generateLinkPath diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 639446f..6d6cee8 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -38,16 +38,20 @@
+{% if current_user.is_authenticated %} {% for emoji, kind in [('๐Ÿ‘', 'up'), ('๐Ÿ‘Ž', 'down'), ('โค๏ธ', 'heart')] %} {% set count = post.reactions | selectattr('kind', 'eq', kind) | list | length %} - {% set reacted = current_user.is_authenticated and post.reactions | selectattr('kind','eq',kind) | selectattr('user_id','eq',current_user.id) | list %} + {% set reacted = post.reactions | selectattr('kind','eq',kind) | selectattr('user_id','eq',current_user.id) | list %} {% endfor %} +{% else %} +Log in to react to this post +{% endif %}
From 042f200743861e26e27ed71b04e40582a561dce2 Mon Sep 17 00:00:00 2001 From: james Date: Fri, 10 Apr 2026 20:25:09 -0400 Subject: [PATCH 52/68] Dms.py created, DM class created, routes and blueprint created, sender and recipient relationships established, blueprint --- forum/DMs.py | 35 +++++++++++++++++++++++++++++++++++ forum/Reactions.py | 3 --- forum/__init__.py | 1 + forum/templates/viewpost.html | 14 ++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 forum/DMs.py diff --git a/forum/DMs.py b/forum/DMs.py new file mode 100644 index 0000000..5bb01a8 --- /dev/null +++ b/forum/DMs.py @@ -0,0 +1,35 @@ +from flask import Blueprint, render_template, request, redirect +from flask_login import login_required, current_user +from forum.models import User, Message, db +import datetime + +class DM(db.Model): + id = db.Column(db.Integer, primary_key=True) + content = db.Column(db.Text) + postdate = db.Column(db.DateTime) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) + recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) + read = db.Column(db.Boolean, default=False) + +rt_DM = Blueprint('rt_DM', __name__, template_folder='templates') + +@rt_DM.route('/messages') +@login_required +def messages(): + msgs = DM.query.filter_by(recipient_id=current_user.id).order_by(DM.id.desc()).all() + return render_template('messages.html', messages=msgs) + +@rt_DM.route('/action_message', methods=['POST']) +@login_required +def action_message(): + recipient = User.query.filter_by(username=request.form['recipient']).first() + if not recipient: + return redirect('/messages') + msg = DM(content=request.form['content'], postdate=datetime.datetime.now(), + sender_id=current_user.id, recipient_id=recipient.id) + db.session.add(msg) + db.session.commit() + return redirect('/messages') + +sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_messages') +recipient = db.relationship('User', foreign_keys=[recipient_id], backref='received_messages') \ No newline at end of file diff --git a/forum/Reactions.py b/forum/Reactions.py index 0ee7fe9..43a0b45 100644 --- a/forum/Reactions.py +++ b/forum/Reactions.py @@ -2,10 +2,7 @@ from flask import request, redirect, Blueprint, request, redirect from flask_login import current_user from flask_login.utils import login_required -import datetime from .models import db -from .user import username_taken, email_taken, valid_username -from .subforum import Subforum, db, generateLinkPath rt_react = Blueprint('rt_react', __name__) diff --git a/forum/__init__.py b/forum/__init__.py index a8974e9..69fac52 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -17,6 +17,7 @@ def create_app(): app.register_blueprint(post_rt) app.register_blueprint(subforum_rt) app.register_blueprint(rt_react) + app.register_blueprint(rt_DM) # Set globals from .models import db db.init_app(app) diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 6d6cee8..b26a0b8 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -109,4 +109,18 @@ {% endblock %} +{% extends 'layout.html' %} +{% block body %} + + + + + +{% for m in messages %} +
+
from {{ m.sender.username }} โ€” {{ m.postdate }}
+
{{ m.content }}
+
+{% endfor %} +{% endblock %} From 4556b1dcf3004cd7b9f7a4e83abf882a3aa1d0e0 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 20:35:20 -0400 Subject: [PATCH 53/68] fixed some bugs in dms and made dm.html file --- forum/DMs.py | 6 ++-- forum/__init__.py | 1 + forum/templates/dm.html | 14 +++++++++ forum/templates/viewpost.html | 15 +-------- forum/user.py | 59 ----------------------------------- 5 files changed, 19 insertions(+), 76 deletions(-) create mode 100644 forum/templates/dm.html diff --git a/forum/DMs.py b/forum/DMs.py index 5bb01a8..6b60d34 100644 --- a/forum/DMs.py +++ b/forum/DMs.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, redirect from flask_login import login_required, current_user -from forum.models import User, Message, db +from .models import User, db import datetime class DM(db.Model): @@ -10,6 +10,8 @@ class DM(db.Model): sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) read = db.Column(db.Boolean, default=False) + sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_messages') + recipient = db.relationship('User', foreign_keys=[recipient_id], backref='received_messages') rt_DM = Blueprint('rt_DM', __name__, template_folder='templates') @@ -31,5 +33,3 @@ def action_message(): db.session.commit() return redirect('/messages') -sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_messages') -recipient = db.relationship('User', foreign_keys=[recipient_id], backref='received_messages') \ No newline at end of file diff --git a/forum/__init__.py b/forum/__init__.py index 69fac52..3bb999f 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -3,6 +3,7 @@ from .post import post_rt from .subforum import subforum_rt from .Reactions import rt_react +from .DMs import rt_DM def create_app(): """Construct the core application.""" diff --git a/forum/templates/dm.html b/forum/templates/dm.html new file mode 100644 index 0000000..f16f0a5 --- /dev/null +++ b/forum/templates/dm.html @@ -0,0 +1,14 @@ +{% extends 'layout.html' %} +{% block body %} +
+ + + +
+{% for m in messages %} +
+
from {{ m.sender.username }} โ€” {{ m.postdate }}
+
{{ m.content }}
+
+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index b26a0b8..4f8ac35 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -109,18 +109,5 @@ {% endblock %} -{% extends 'layout.html' %} -{% block body %} -
- - - -
-{% for m in messages %} -
-
from {{ m.sender.username }} โ€” {{ m.postdate }}
-
{{ m.content }}
-
-{% endfor %} -{% endblock %} + diff --git a/forum/user.py b/forum/user.py index b1716fa..7d97673 100644 --- a/forum/user.py +++ b/forum/user.py @@ -53,62 +53,3 @@ def username_taken(username): def email_taken(email): return User.query.filter(User.email == email).first() - -########################################################################## -import hashlib -import hmac -import secrets - - -class User: - def __init__(self, id, username, password, email, - is_admin=False, permissions=None, privacy="public"): - - # --- Core Fields --- - self.id = id # Primary Key - self.username = username - self.password_hash = self._hash_password(password) - self.email = email - - # --- Admin / Settings --- - self.is_admin = is_admin - self.permissions = permissions if permissions else [] - self.privacy = privacy - - # --- Relationships (instead of FK fields) --- - self.post_fk = [] # list of post IDs - self.comments_fk = [] # list of comment IDs - - # ------------------------- - # Helper Methods - # ------------------------- - - @staticmethod - def _hash_password(password): - salt = secrets.token_hex(16) - digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() - return f"{salt}${digest}" - - def check_password(self, password): - salt, stored_digest = self.password_hash.split("$", 1) - digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() - return hmac.compare_digest(stored_digest, digest) - - def add_post(self, post_id): - self.post_fk.append(post_id) - - def add_comment(self, comment_id): - self.comments_fk.append(comment_id) - - def set_privacy(self, privacy): - self.privacy = privacy - - def add_permission(self, permission): - if permission not in self.permissions: - self.permissions.append(permission) - - def is_allowed(self, permission): - return self.is_admin or permission in self.permissions - - def __repr__(self): - return f"" From 0f8380efbf606f98051b8541928e3002f0e1aa0c Mon Sep 17 00:00:00 2001 From: james Date: Fri, 10 Apr 2026 20:58:23 -0400 Subject: [PATCH 54/68] Direct messaging buttons added to front end. --- forum/templates/DM.html | 19 +++++++++++++++++++ forum/templates/header.html | 4 +++- forum/templates/subforum.html | 3 +++ forum/templates/viewpost.html | 8 ++++++-- 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 forum/templates/DM.html diff --git a/forum/templates/DM.html b/forum/templates/DM.html new file mode 100644 index 0000000..9a1d5b5 --- /dev/null +++ b/forum/templates/DM.html @@ -0,0 +1,19 @@ +{% extends 'layout.html' %} +{% block body %} +
+ + + +
+{% for m in messages %} +
+
from {{ m.sender.username }} โ€” {{ m.postdate }}
+
{{ m.content }}
+ Reply +
+{% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/forum/templates/header.html b/forum/templates/header.html index 5bf57b4..a27387e 100644 --- a/forum/templates/header.html +++ b/forum/templates/header.html @@ -1,7 +1,9 @@ {{ config.SITE_NAME }}{% if config.SITE_DESCRIPTION %} - {% endif %} {{ config.SITE_DESCRIPTION }}
by {{post.user.username}} + {% if current_user.is_authenticated and current_user.id != post.user.id %} + (message) + {% endif %}
{{ post.get_time_string() }}
diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 4f8ac35..9fa1fea 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -8,7 +8,9 @@ {% if post.private %}๐Ÿ”’ Private{% endif %}
{{ post.user.username }} - + {% if current_user.is_authenticated and current_user.id != post.user.id %} + (message) + {% endif %}
{{ post.get_time_string() }} @@ -76,7 +78,9 @@
- ({{ comment.user.username }}) - + ({{ comment.user.username }} {% if current_user.is_authenticated and current_user.id != comment.user.id %} + (message) + {% endif %}) -
{{ comment.content }} From 48a36c8f19d72b21779aa26ad6e5ad63775f47e9 Mon Sep 17 00:00:00 2001 From: nate Date: Sat, 11 Apr 2026 15:23:01 -0400 Subject: [PATCH 55/68] remove dm.html permanently --- .gitignore | 3 +++ forum/templates/DM.html | 19 ------------------- forum/templates/dm.html | 14 -------------- 3 files changed, 3 insertions(+), 33 deletions(-) delete mode 100644 forum/templates/DM.html delete mode 100644 forum/templates/dm.html diff --git a/.gitignore b/.gitignore index 65fdf95..1fad05f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ venv/ .vscode +# DM feature removed โ€” prevent it from ever being re-added +forum/templates/[Dd][Mm].html + # Uploaded user files โ€” kept locally only, not committed forum/static/uploads/* !forum/static/uploads/.gitkeep diff --git a/forum/templates/DM.html b/forum/templates/DM.html deleted file mode 100644 index 9a1d5b5..0000000 --- a/forum/templates/DM.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'layout.html' %} -{% block body %} -
- - - -
-{% for m in messages %} -
-
from {{ m.sender.username }} โ€” {{ m.postdate }}
-
{{ m.content }}
- Reply -
-{% endfor %} - -{% endblock %} \ No newline at end of file diff --git a/forum/templates/dm.html b/forum/templates/dm.html deleted file mode 100644 index f16f0a5..0000000 --- a/forum/templates/dm.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'layout.html' %} -{% block body %} -
- - - -
-{% for m in messages %} -
-
from {{ m.sender.username }} โ€” {{ m.postdate }}
-
{{ m.content }}
-
-{% endfor %} -{% endblock %} \ No newline at end of file From 518533be92f1146ee408a4a5bf772e3449d4c9f3 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 10 Apr 2026 22:07:59 -0400 Subject: [PATCH 56/68] attempt to resolve file problems with commits --- forum/DMs.py | 35 ----------------------------- forum/__init__.py | 4 ++-- forum/messages.py | 42 +++++++++++++++++++++++++++++++++++ forum/templates/messages.html | 28 +++++++++++++++++++++++ 4 files changed, 72 insertions(+), 37 deletions(-) delete mode 100644 forum/DMs.py create mode 100644 forum/messages.py create mode 100644 forum/templates/messages.html diff --git a/forum/DMs.py b/forum/DMs.py deleted file mode 100644 index 6b60d34..0000000 --- a/forum/DMs.py +++ /dev/null @@ -1,35 +0,0 @@ -from flask import Blueprint, render_template, request, redirect -from flask_login import login_required, current_user -from .models import User, db -import datetime - -class DM(db.Model): - id = db.Column(db.Integer, primary_key=True) - content = db.Column(db.Text) - postdate = db.Column(db.DateTime) - sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) - recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) - read = db.Column(db.Boolean, default=False) - sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_messages') - recipient = db.relationship('User', foreign_keys=[recipient_id], backref='received_messages') - -rt_DM = Blueprint('rt_DM', __name__, template_folder='templates') - -@rt_DM.route('/messages') -@login_required -def messages(): - msgs = DM.query.filter_by(recipient_id=current_user.id).order_by(DM.id.desc()).all() - return render_template('messages.html', messages=msgs) - -@rt_DM.route('/action_message', methods=['POST']) -@login_required -def action_message(): - recipient = User.query.filter_by(username=request.form['recipient']).first() - if not recipient: - return redirect('/messages') - msg = DM(content=request.form['content'], postdate=datetime.datetime.now(), - sender_id=current_user.id, recipient_id=recipient.id) - db.session.add(msg) - db.session.commit() - return redirect('/messages') - diff --git a/forum/__init__.py b/forum/__init__.py index 3bb999f..dc70398 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -3,7 +3,7 @@ from .post import post_rt from .subforum import subforum_rt from .Reactions import rt_react -from .DMs import rt_DM +from .messages import rt_messages def create_app(): """Construct the core application.""" @@ -18,7 +18,7 @@ def create_app(): app.register_blueprint(post_rt) app.register_blueprint(subforum_rt) app.register_blueprint(rt_react) - app.register_blueprint(rt_DM) + app.register_blueprint(rt_messages) # Set globals from .models import db db.init_app(app) diff --git a/forum/messages.py b/forum/messages.py new file mode 100644 index 0000000..40dea80 --- /dev/null +++ b/forum/messages.py @@ -0,0 +1,42 @@ +from flask import Blueprint, render_template, request, redirect +from flask_login import login_required, current_user +from .models import User, db +import datetime + +class Messages(db.Model): + id = db.Column(db.Integer, primary_key=True) + content = db.Column(db.Text) + postdate = db.Column(db.DateTime) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) + recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) + read = db.Column(db.Boolean, default=False) + sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_messages') + recipient = db.relationship('User', foreign_keys=[recipient_id], backref='received_messages') + +rt_messages = Blueprint('rt_messages', __name__, template_folder='templates') + +@rt_messages.route('/messages') +@login_required +def messages(): + sender_filter = request.args.get('sender') + query = Messages.query.filter_by(recipient_id=current_user.id) + if sender_filter: + sender = User.query.filter_by(username=sender_filter).first() + if sender: + query = query.filter_by(sender_id=sender.id) + msgs = query.order_by(Messages.id.desc()).all() + senders = User.query.join(Messages, Messages.sender_id == User.id).filter(Messages.recipient_id == current_user.id).distinct().all() + return render_template('messages.html', messages=msgs, senders=senders, current_sender=sender_filter) + +@rt_messages.route('/action_message', methods=['POST']) +@login_required +def action_message(): + recipient = User.query.filter_by(username=request.form['recipient']).first() + if not recipient: + return render_template('messages.html', errors=["That username does not exist."], messages=Messages.query.filter_by(recipient_id=current_user.id).order_by(Messages.id.desc()).all(), senders=User.query.join(Messages, Messages.sender_id == User.id).filter(Messages.recipient_id == current_user.id).distinct().all(), current_sender=None) + msg = Messages(content=request.form['content'], postdate=datetime.datetime.now(), + sender_id=current_user.id, recipient_id=recipient.id) + db.session.add(msg) + db.session.commit() + return redirect('/messages') + diff --git a/forum/templates/messages.html b/forum/templates/messages.html new file mode 100644 index 0000000..bbcc6eb --- /dev/null +++ b/forum/templates/messages.html @@ -0,0 +1,28 @@ +{% extends 'layout.html' %} +{% block body %} +
+ + + +
+
+ Filter by sender: + +
+{% for m in messages %} +
+
from {{ m.sender.username }} โ€” {{ m.postdate }}
+
{{ m.content }}
+ Reply +
+{% endfor %} + +{% endblock %} \ No newline at end of file From 2b6ae6c9ef5509da77cfda947fae0c3d23c34bf9 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 11 Apr 2026 12:46:07 -0400 Subject: [PATCH 57/68] User class implemented --- forum/app.py | 3 +- forum/ignore.py | 146 ---------------------------------------------- forum/messages.py | 3 +- forum/models.py | 36 ++++++------ forum/post.py | 5 +- forum/routes.py | 4 +- forum/user.py | 39 +++++++++++-- 7 files changed, 60 insertions(+), 176 deletions(-) delete mode 100644 forum/ignore.py diff --git a/forum/app.py b/forum/app.py index 9c5d894..5d92275 100644 --- a/forum/app.py +++ b/forum/app.py @@ -8,8 +8,9 @@ from flask import render_template from flask_login import LoginManager from sqlalchemy import inspect, text -from .models import db, User +from .models import db from .subforum import Subforum +from .user import User from forum import create_app # Build the Flask app using the package factory. diff --git a/forum/ignore.py b/forum/ignore.py deleted file mode 100644 index 3e40e86..0000000 --- a/forum/ignore.py +++ /dev/null @@ -1,146 +0,0 @@ -def try_admin_connection(): - """Try common local root login methods and return (connection, method).""" - socket_paths = [ - "/tmp/mysql.sock", - "/var/run/mysql/mysql.sock", - "/usr/local/var/run/mysql.sock", - ] - - for socket_path in socket_paths: - try: - admin_conn = pymysql.connect( - user="root", - unix_socket=socket_path, - charset="utf8mb4", - ) - return (admin_conn, f"socket ({socket_path})") - except Exception: - continue - - try: - admin_conn = pymysql.connect( - host="127.0.0.1", - port=3306, - user="root", - password="", - charset="utf8mb4", - ) - return (admin_conn, "TCP/IP (no password)") - except Exception: - pass - - root_password = environ.get("MYSQL_ROOT_PASSWORD", "") - if root_password: - try: - admin_conn = pymysql.connect( - host="127.0.0.1", - port=3306, - user="root", - password=root_password, - charset="utf8mb4", - ) - return (admin_conn, "TCP/IP (with password)") - except Exception: - pass - - return (None, None) - - -def setup_database_and_user(): - """Best-effort local setup for app database and app user.""" - app_user = "zipchat_app" - app_password = "password" - db_host = "127.0.0.1" - db_name = "ZipChat" - - admin_conn, method = try_admin_connection() - - if admin_conn is None: - print("\nCould not connect to MySQL as root") - print(" Socket connection not available") - print(" TCP/IP connection with no password did not work") - print("\n To fix:") - print(" A) brew services restart mysql") - print(" B) export MYSQL_ROOT_PASSWORD='your_root_password' && bash ./run.sh") - print(" C) Or create database and user manually") - print("") - return False - - try: - print(f"Connected to MySQL as root via {method}") - - with admin_conn.cursor() as cursor: - cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;") - - try: - cursor.execute( - f"CREATE USER IF NOT EXISTS '{app_user}'@'{db_host}' IDENTIFIED BY '{app_password}';" - ) - except pymysql.err.OperationalError as err: - if "already exists" not in str(err): - raise - - cursor.execute( - f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{app_user}'@'{db_host}';" - ) - cursor.execute("FLUSH PRIVILEGES;") - - admin_conn.commit() - admin_conn.close() - print("MySQL setup complete\n") - return True - - except Exception as err: - print(f"Error during setup: {err}\n") - admin_conn.close() - return False - - -def check_mysql_connection(): - """Verify runtime app credentials can connect to MySQL.""" - app_user = environ.get("DB_USER", "zipchat_app") - app_password = environ.get("DB_PASSWORD", "password") - db_host = environ.get("DB_HOST", "127.0.0.1") - db_port = int(environ.get("DB_PORT", "3306")) - db_name = environ.get("DB_NAME", "ZipChat") - - try: - connection = pymysql.connect( - host=db_host, - port=db_port, - user=app_user, - password=app_password, - database=db_name, - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, - ) - connection.close() - print(f"MySQL ready: {app_user}@{db_name}\n") - return True - - except pymysql.err.OperationalError as err: - error_code = err.args[0] if err.args else None - - if error_code == 2003: - print(f"MySQL not running on {db_host}:{db_port}") - print("Start it: brew services start mysql\n") - return False - - if error_code == 1045: - print(f"Cannot login as '{app_user}'") - print("Either the user does not exist or the password is wrong.\n") - return False - - if error_code == 1049: - print(f"Database '{db_name}' does not exist\n") - return False - - print(f"MySQL error ({error_code}): {err}\n") - return False - - -def check_mysql_and_setup(): - """Run setup then verify runtime MySQL connectivity.""" - print("Checking MySQL setup...") - setup_database_and_user() - check_mysql_connection() \ No newline at end of file diff --git a/forum/messages.py b/forum/messages.py index 40dea80..6ebfc68 100644 --- a/forum/messages.py +++ b/forum/messages.py @@ -1,7 +1,8 @@ from flask import Blueprint, render_template, request, redirect from flask_login import login_required, current_user -from .models import User, db +from .models import db import datetime +from .user import User class Messages(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/forum/models.py b/forum/models.py index e616d26..9d52276 100644 --- a/forum/models.py +++ b/forum/models.py @@ -9,24 +9,24 @@ db = SQLAlchemy() # Database models -class User(UserMixin, db.Model): - # Store account information and ownership of posts/comments. - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80), unique=True, nullable=False) - password_hash = db.Column(db.String(255), nullable=False) - email = db.Column(db.String(255), unique=True, nullable=False) - admin = db.Column(db.Boolean, default=False) - posts = db.relationship("Post", backref="user") - - def __init__(self, email, username, password): - # Save the hashed password instead of the plain text password. - self.email = email - self.username = username - self.password_hash = generate_password_hash(password) - - def check_password(self, password): - # Compare a password guess against the stored hash. - return check_password_hash(self.password_hash, password) +# class User(UserMixin, db.Model): +# # Store account information and ownership of posts/comments. +# id = db.Column(db.Integer, primary_key=True) +# username = db.Column(db.String(80), unique=True, nullable=False) +# password_hash = db.Column(db.String(255), nullable=False) +# email = db.Column(db.String(255), unique=True, nullable=False) +# admin = db.Column(db.Boolean, default=False) +# posts = db.relationship("Post", backref="user") + +# def __init__(self, email, username, password): +# # Save the hashed password instead of the plain text password. +# self.email = email +# self.username = username +# self.password_hash = generate_password_hash(password) + +# def check_password(self, password): +# # Compare a password guess against the stored hash. +# return check_password_hash(self.password_hash, password) # Post is defined in post.py; imported here after db is ready to avoid # circular imports while keeping Post in its own module. diff --git a/forum/post.py b/forum/post.py index 56477b9..545ab00 100644 --- a/forum/post.py +++ b/forum/post.py @@ -3,10 +3,9 @@ from flask import Blueprint, render_template, request, redirect, url_for, current_app, send_from_directory from flask_login import current_user from flask_login.utils import login_required -from httpx import post from werkzeug.utils import secure_filename -from .models import db, User, valid_content, valid_title, error -import httpx +from .models import db, valid_content, valid_title, error +from .user import User post_rt = Blueprint('post_routes', __name__, template_folder='templates') diff --git a/forum/routes.py b/forum/routes.py index 6353e8a..abb43e8 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,10 +3,10 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from .models import User, valid_content, valid_title, db, error +from .models import valid_content, valid_title, db, error from .post import Post from .subforum import Subforum, generateLinkPath -from .user import username_taken, email_taken, valid_username +from .user import username_taken, email_taken, valid_username, User # Route handlers for login, browsing, and content creation. # The app is small enough to keep in one blueprint for now. diff --git a/forum/user.py b/forum/user.py index 7d97673..bbdb1a5 100644 --- a/forum/user.py +++ b/forum/user.py @@ -1,13 +1,12 @@ from werkzeug.security import generate_password_hash, check_password_hash from flask_login import UserMixin import datetime - +from .models import db # Shared SQLAlchemy object used by the app factory and all models. from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() +#db = SQLAlchemy() -# Database models class User(UserMixin, db.Model): # Store account information and ownership of posts/comments. id = db.Column(db.Integer, primary_key=True) @@ -15,12 +14,36 @@ class User(UserMixin, db.Model): password_hash = db.Column(db.String(255), nullable=False) email = db.Column(db.String(255), unique=True, nullable=False) admin = db.Column(db.Boolean, default=False) + privacy = db.Column(db.String(20), default="public") posts = db.relationship("Post", backref="user") - def __init__(self, email, username, password): + def __init__(self, email, username, password, privacy="public", admin=False): # Save the hashed password instead of the plain text password. self.email = email self.username = username + self.set_password(password) + self.privacy = privacy + self.admin = admin + + @property + def password(self): + # Passwords are write-only and stored as a hash. + raise AttributeError("password is write-only") + + @password.setter + def password(self, password): + self.set_password(password) + + @property + def is_admin(self): + return self.admin + + @is_admin.setter + def is_admin(self, value): + self.admin = value + + + def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): @@ -28,7 +51,13 @@ def check_password(self, password): return check_password_hash(self.password_hash, password) -from .models import User + def set_privacy(self, privacy): + if privacy not in ["public", "private"]: + raise ValueError("Privacy must be 'public' or 'private'") + self.privacy = privacy + + +# from .models import User import re From 27fae190946d2ac9e6025b1647c1125c8ad31d3d Mon Sep 17 00:00:00 2001 From: david Date: Sat, 11 Apr 2026 13:02:28 -0400 Subject: [PATCH 58/68] user routing merged --- forum/__init__.py | 2 + forum/routes.py | 108 +++++++++++++++++++++++----------------------- forum/user.py | 80 +++++++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 55 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index dc70398..df1db01 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -4,6 +4,7 @@ from .subforum import subforum_rt from .Reactions import rt_react from .messages import rt_messages +from .user import auth_bp def create_app(): """Construct the core application.""" @@ -19,6 +20,7 @@ def create_app(): app.register_blueprint(subforum_rt) app.register_blueprint(rt_react) app.register_blueprint(rt_messages) + app.register_blueprint(auth_bp) # Set globals from .models import db db.init_app(app) diff --git a/forum/routes.py b/forum/routes.py index abb43e8..0e4b9ab 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -13,56 +13,56 @@ rt = Blueprint('routes', __name__, template_folder='templates') -@rt.route('/action_login', methods=['POST']) -def action_login(): - # Read login form values and authenticate the user. - username = request.form['username'] - password = request.form['password'] - user = User.query.filter(User.username == username).first() - if user and user.check_password(password): - login_user(user) - else: - errors = [] - errors.append("Username or password is incorrect!") - return render_template("login.html", errors=errors) - return redirect("/") - -@login_required -@rt.route('/action_logout') -def action_logout(): - # End the current session and send the user back home. - logout_user() - return redirect("/") - -@rt.route('/action_createaccount', methods=['POST']) -def action_createaccount(): - # Validate signup data, create the user, then log them in. - username = request.form['username'] - password = request.form['password'] - email = request.form['email'] - errors = [] - retry = False - if username_taken(username): - errors.append("Username is already taken!") - retry = True - if email_taken(email): - errors.append("An account already exists with this email!") - retry = True - if not valid_username(username): - errors.append("Username is not valid!") - retry = True - # if not valid_password(password): - # errors.append("Password is not valid!") - # retry = True - if retry: - return render_template("login.html", errors=errors) - user = User(email, username, password) - if user.username == "admin": - user.admin = True - db.session.add(user) - db.session.commit() - login_user(user) - return redirect("/") +# @rt.route('/action_login', methods=['POST']) +# def action_login(): +# # Read login form values and authenticate the user. +# username = request.form['username'] +# password = request.form['password'] +# user = User.query.filter(User.username == username).first() +# if user and user.check_password(password): +# login_user(user) +# else: +# errors = [] +# errors.append("Username or password is incorrect!") +# return render_template("login.html", errors=errors) +# return redirect("/") + +# @login_required +# @rt.route('/action_logout') +# def action_logout(): +# # End the current session and send the user back home. +# logout_user() +# return redirect("/") + +# @rt.route('/action_createaccount', methods=['POST']) +# def action_createaccount(): +# # Validate signup data, create the user, then log them in. +# username = request.form['username'] +# password = request.form['password'] +# email = request.form['email'] +# errors = [] +# retry = False +# if username_taken(username): +# errors.append("Username is already taken!") +# retry = True +# if email_taken(email): +# errors.append("An account already exists with this email!") +# retry = True +# if not valid_username(username): +# errors.append("Username is not valid!") +# retry = True +# # if not valid_password(password): +# # errors.append("Password is not valid!") +# # retry = True +# if retry: +# return render_template("login.html", errors=errors) +# user = User(email, username, password) +# if user.username == "admin": +# user.admin = True +# db.session.add(user) +# db.session.commit() +# login_user(user) +# return redirect("/") # @rt.route('/subforum') @@ -81,10 +81,10 @@ def action_createaccount(): # subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() # return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) -@rt.route('/loginform') -def loginform(): - # Render the shared login and signup page. - return render_template("login.html") +# @rt.route('/loginform') +# def loginform(): +# # Render the shared login and signup page. +# return render_template("login.html") # @login_required diff --git a/forum/user.py b/forum/user.py index bbdb1a5..4f18a85 100644 --- a/forum/user.py +++ b/forum/user.py @@ -67,6 +67,7 @@ def set_privacy(self, privacy): password_regex = re.compile("^[a-zA-Z0-9!@#%&]{6,40}$") username_regex = re.compile("^[a-zA-Z0-9!@#%&]{4,40}$") +email_regex = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") #Account checks def valid_username(username): if not username_regex.match(username): @@ -81,4 +82,81 @@ def username_taken(username): return User.query.filter(User.username == username).first() def email_taken(email): return User.query.filter(User.email == email).first() - +def valid_email(email): + return bool(email_regex.match(email)) + +######################################################################### + +from flask import Blueprint, redirect, render_template, request +from flask_login import login_required, login_user, logout_user + + +#from .user import email_taken, username_taken, valid_email, valid_password, valid_username + + +auth_bp = Blueprint("auth", __name__, template_folder="templates") + + +@auth_bp.route('/action_login', methods=['POST']) +def action_login(): + username = request.form['username'] + password = request.form['password'] + user = User.query.filter(User.username == username).first() + if user and user.check_password(password): + login_user(user) + else: + return render_template("login.html", errors=["Username or password is incorrect!"]) + return redirect("/") + + +@auth_bp.route('/action_logout') +@login_required +def action_logout(): + logout_user() + return redirect("/") + + +@auth_bp.route('/action_createaccount', methods=['POST']) +def action_createaccount(): + username = request.form['username'] + password = request.form['password'] + email = request.form['email'] + privacy = request.form.get('privacy', 'public') + errors = [] + retry = False + + if username_taken(username): + errors.append("Username is already taken!") + retry = True + if email_taken(email): + errors.append("An account already exists with this email!") + retry = True + if not valid_username(username): + errors.append("Username is not valid!") + retry = True + if not valid_password(password): + errors.append("Password must be 6-40 characters and contain only letters, numbers, and !@#%&") + retry = True + if not valid_email(email): + errors.append("Email is not valid!") + retry = True + if privacy not in ["public", "private"]: + errors.append("Privacy must be either public or private") + retry = True + + if retry: + return render_template("login.html", errors=errors) + + user = User(email, username, password, privacy=privacy) + if user.username == "admin": + user.admin = True + user.add_permission("admin:all") + db.session.add(user) + db.session.commit() + login_user(user) + return redirect("/") + + +@auth_bp.route('/loginform') +def loginform(): + return render_template("login.html") \ No newline at end of file From 1f97cccc9bcf2fc61816f69874b819ba6233a8b7 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 11 Apr 2026 13:14:00 -0400 Subject: [PATCH 59/68] style coloring frontend --- forum/static/js.txt | 7 + forum/static/style.css | 698 ++++++++++++++++++++++++++++++----------- 2 files changed, 529 insertions(+), 176 deletions(-) create mode 100644 forum/static/js.txt diff --git a/forum/static/js.txt b/forum/static/js.txt new file mode 100644 index 0000000..e4526f1 --- /dev/null +++ b/forum/static/js.txt @@ -0,0 +1,7 @@ +function submitPost() { + const input = document.getElementById("composeInput"); + if (!input.value.trim()) return; + + alert("Post submitted: " + input.value); + input.value = ""; +} \ No newline at end of file diff --git a/forum/static/style.css b/forum/static/style.css index f23281d..9aa3598 100644 --- a/forum/static/style.css +++ b/forum/static/style.css @@ -1,200 +1,546 @@ -.page{ - font-family: Verdana; - font-style: normal; - font-size: 14pt; - - margin-left: auto; - margin-right: auto; - margin-top: 1%; - width: 80%; - - padding-left: 3%; - padding-right: 3%; - -} -.header{ - border: 1px solid black; - overflow: hidden; - vertical-align: middle; - padding: 0.5%; - margin-bottom: none; -} -.content{ - margin-top: 0.5%; -} -.login{ - width: 25%; - float: right; - text-align: center; - -} -.loginbox{ - margin-right: auto; - margin-left: auto; - text-align: center; - margin-top: 3%; -} -.loginelement{ - margin-top: 0.5%; -} -.logoutlink{ - padding-left: 0.5%; - font-size: 12pt; -} -.adminusername{ - color: red; -} -.centertext{ - text-align: center; -} -.comment-submit{ - margin-bottom: 1%; -} -.postsubmit{ - margin-left: auto; - margin-right: auto; - text-align: center; - -} -.subforumlisting{ - border-bottom: 1px solid gray; - padding: 1%; - margin-bottom: 1%; -} -.inputbox{ - padding: 0.25%; - margin-top: 0.5%; -} -.errors{ - border-bottom: 1px solid black; - margin-top: 2%; -} -.error{ - color: red; - font-weight: bold; -} -.subforumtitle{ - color: blue; - font-size: 15pt; - font-weight: bold; -} -.subforumdesc{ - font-size: 13pt; - font-style: italic; -} -.subforumheader{ - padding: 0.5%; - padding-left: 1%; - margin-top: none; - padding-bottom: none; - margin-bottom: 0.5%; - border: 1px solid black; - font-size: 15pt; -} -.subforumheadertitle{ - display: inline; - font-weight: bold; -} -.subforumheaderdesc{ - display: inline; -} -.post{ - padding: 1%; - border-bottom: 1px solid gray; +:root { + --zc-green: #22c55e; + --zc-green-dark: #16a34a; + --zc-green-light: #bbf7d0; + --zc-green-pale: #f0fdf4; + --zc-blue: #3b82f6; + --zc-blue-dark: #1d4ed8; + --zc-blue-light: #bfdbfe; + --zc-blue-pale: #eff6ff; + --zc-bg: #f0fdf9; + --zc-surface: #ffffff; + --zc-border: #d1fae5; + --zc-text: #0f172a; + --zc-muted: #64748b; + --zc-subtle: #94a3b8; + --zc-radius: 16px; + --zc-radius-sm: 10px; + --zc-shadow: 0 4px 24px rgba(34, 197, 94, 0.1); + --zc-shadow-blue: 0 4px 24px rgba(59, 130, 246, 0.15); } -.postusername{ - font-style: italic; - font-size: 12pt; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + background: var(--zc-bg); + color: var(--zc-text); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 700px 500px at 0% 0%, rgba(34, 197, 94, 0.13) 0%, transparent 65%), + radial-gradient(ellipse 600px 450px at 100% 80%, rgba(59, 130, 246, 0.14) 0%, transparent 65%); +} + +@keyframes meshDrift { + from { + transform: scale(1) translate(0, 0); + } + to { + transform: scale(1.07) translate(24px, 16px); + } +} + +.page-wrap { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.zc-navbar { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(18px); + border-bottom: 1px solid var(--zc-green-light); + padding: 12px 24px; + display: flex; + align-items: center; + gap: 20px; +} + +.zc-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +.zc-logo-icon { + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 900; +} + +.zc-logo-text { + font-size: 20px; + font-weight: 800; + letter-spacing: 1px; +} + +.zc-logo-text span { + color: var(--zc-blue); +} + +.zc-nav-right { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; +} + +.zc-nav-links { + display: flex; + align-items: center; + gap: 4px; + margin-left: 12px; +} + +.zc-nav-link { + padding: 7px 16px; + border-radius: 22px; + font-size: 13px; + font-weight: 600; + color: var(--zc-muted); + border: 1.5px solid transparent; + transition: all 0.2s ease; + text-decoration: none; + cursor: pointer; + background: transparent; + white-space: nowrap; +} + +.zc-nav-link:hover, +.zc-nav-link.active { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.4); + color: var(--zc-green-dark); +} + +.zc-nav-user { + font-weight: 700; + color: var(--zc-text); +} + +.btn-zc-primary, +.btn-zc-ghost { + text-decoration: none; + font-size: 13px; + font-weight: 700; + border-radius: 22px; + padding: 8px 14px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-zc-primary { + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + border: none; +} + +.btn-zc-ghost { + color: var(--zc-muted); + border: 1px solid var(--zc-green-light); + background: #fff; +} + +.zc-hero { + background: linear-gradient(135deg, rgba(187, 247, 208, 0.55), rgba(191, 219, 254, 0.55)); + border-bottom: 1px solid var(--zc-green-light); + padding: 22px 32px; + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} + +.zc-hero h1 { + margin: 0; + font-size: 26px; + font-weight: 800; +} + +.zc-hero h1 span { + color: var(--zc-blue); +} + +.zc-hero p { + margin: 4px 0 0; + color: var(--zc-muted); +} + +.zc-stats { + display: flex; + gap: 12px; + margin-left: auto; + flex-wrap: wrap; +} + +.zc-stat-pill { + background: rgba(255, 255, 255, 0.78); + border: 1.5px solid var(--zc-green-light); + border-radius: 14px; + padding: 10px 22px; + text-align: center; +} + +.zc-stat-num { + font-size: 22px; + font-weight: 800; + color: var(--zc-blue); + line-height: 1; +} + +.zc-stat-lbl { + font-size: 11px; + color: var(--zc-muted); + margin-top: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.zc-layout { + width: 100%; + max-width: 1260px; + margin: 0 auto; + display: grid; + grid-template-columns: 210px 1fr 230px; + gap: 24px; + padding: 24px 32px; + flex: 1; +} + +.glass-card { + background: rgba(255, 255, 255, 0.78); + border: 1px solid var(--zc-green-light); + border-radius: var(--zc-radius); + box-shadow: var(--zc-shadow); + padding: 14px; +} + +.zc-sidebar-label, +.zc-widget-title { + font-size: 10px; + font-weight: 800; + letter-spacing: 1.5px; + color: var(--zc-subtle); + text-transform: uppercase; + margin-bottom: 8px; +} + +.zc-sidebar-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--zc-radius-sm); + text-decoration: none; + color: var(--zc-muted); + font-size: 13px; + font-weight: 600; +} + +.zc-sidebar-link:hover { + background: rgba(34, 197, 94, 0.1); + color: var(--zc-green-dark); +} + +.zc-sidebar-link.active { + background: rgba(59, 130, 246, 0.1); + border-color: rgba(59, 130, 246, 0.35); + color: var(--zc-blue-dark); +} + +.zc-sidebar-badge { + margin-left: auto; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + font-size: 10px; + font-weight: 800; + padding: 2px 8px; + border-radius: 20px; +} + +.zc-widget { + background: rgba(255, 255, 255, 0.75); + border: 1.5px solid var(--zc-green-light); + border-radius: var(--zc-radius); + padding: 16px 18px; + margin-bottom: 16px; +} + +.zc-dm-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 9px; + border-radius: var(--zc-radius-sm); + transition: all 0.18s ease; + margin-bottom: 4px; + border: 1.5px solid transparent; +} + +.zc-dm-row:hover { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.25); +} + +.zc-dm-name { + font-size: 13px; + font-weight: 700; + color: var(--zc-text); +} + +.zc-dm-badge { + width: 19px; + height: 19px; + background: var(--zc-blue); + border-radius: 50%; + color: #fff; + font-size: 10px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; +} + +.zc-muted-text { + margin: 0; + color: var(--zc-muted); + font-size: 13px; + line-height: 1.5; +} + +.zc-feed { + min-width: 0; +} + +.zc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; } -.subforumheaderlink{ - font-style: italic; - font-size: 12pt; - margin-top: 0.5%; + +.zc-section-title { + font-size: 18px; + font-weight: 800; + margin: 0; + display: flex; + align-items: center; + gap: 8px; } -.postinginfo{ - font-size: 11pt; - font-style: italic; + +.zc-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -#nolinks{ - text-decoration: none; + +.zc-card-title { + margin: 0; + font-size: 18px; + font-weight: 800; } -.noposts{ - padding: 1%; + +.zc-card-title a { + text-decoration: none; + color: var(--zc-text); } -.subsubforums{ - border: 1px solid black; - padding: 0.5%; - margin-top: 0.5%; - padding-left: 1%; + +.zc-card-body { + color: var(--zc-muted); + margin: 10px 0; } -.subsubforumtitle{ - font-weight: bolder; - text-decoration: none; - font-size: 14pt; + +.zc-card-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; } -.subsubforumdesc{ - font-style: italic; - font-size: 12pt; + +.zc-chip { + background: #ecfeff; + color: #0f766e; + border: 1px solid #99f6e4; + border-radius: 20px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; } -.toplevelinfo{ - padding: 0.5%; - font-weight: bold; + +.zc-meta { + color: var(--zc-subtle); + font-size: 12px; + margin: 0; } -.actualpost{ - padding: 1%; - border: 1px solid black; - border-collapse: collapse; - margin-bottom: 1%; + +.zc-empty { + background: rgba(255, 255, 255, 0.8); + border: 1.5px dashed var(--zc-green-light); + border-radius: var(--zc-radius); + padding: 20px; + text-align: center; + color: var(--zc-muted); } -.actualposttitle{ - border-collapse: collapse; - border-bottom: 1px solid black; - padding: 1%; - font-size: 16pt; - font-weight: bold; + +.zc-breadcrumb { + margin-bottom: 10px; + color: var(--zc-muted); + font-size: 13px; } -.postcontent{ - padding: 1%; + +.errors { + width: min(1260px, 100% - 40px); + margin: 14px auto 0; } -.postposter{ - font-size: 12pt; - font-style: italic; - color: black; + +.error { + background: #fff1f2; + border: 1px solid #fecdd3; + border-radius: 10px; + padding: 10px 12px; + color: #be123c; + margin: 6px 0; } -.posttime{ - font-style: italic; - font-size: 11pt; + +.zc-footer { + margin-top: auto; + padding: 16px 24px; + border-top: 1px solid var(--zc-green-light); + background: rgba(255, 255, 255, 0.78); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; } -.whitespace{ - white-space: pre-line; + +.zc-footer-brand { + font-size: 16px; + font-weight: 800; } -.comments{ - border: 1px solid gray; - padding: 1%; - margin-top: 1%; + +.zc-footer-brand span { + color: var(--zc-blue); } -.comment{ - border-bottom: 1px solid gray; + +.zc-footer-copy { + color: var(--zc-subtle); + font-size: 12px; } -.commentuser{ - display: inline; - font-weight: bold; + +/* Existing template compatibility */ +.toplevelinfo, +.subforumheader, +.subforumlisting, +.post, +.actualpost, +.comment, +.loginbox, +.subsubforums, +.noposts, +.addcomment, +.comments { + background: rgba(255, 255, 255, 0.8); + border: 1px solid var(--zc-green-light); + border-radius: var(--zc-radius); + box-shadow: var(--zc-shadow); + padding: 14px 16px; + margin-bottom: 12px; } -.commentcontent{ - display: inline; -} -.commenttime{ - font-size: 11pt; - font-style: italic; + +.subforumtitle a, +.subsubforumtitle a, +.posttitle a, +.actualposttitle a { + color: var(--zc-blue-dark); + text-decoration: none; + font-weight: 800; } +.subforumdesc, +.subsubforumdesc, +.postcontent, +.commentcontent, +.postusername, +.commenttime, +.commentuser, +.date, +.posttime { + color: var(--zc-muted); +} -.addcomment{ - display: none; - margin-right: auto; - margin-left: auto; - text-align: center; +.inputbox, +.loginelement, +textarea, +input[type='text'], +input[type='password'], +input[type='email'], +select { + width: 100%; + border: 1px solid var(--zc-green-light); + border-radius: 10px; + padding: 10px 12px; + font: inherit; + background: #fff; } -.varwidth{ - width: 50%; + +input[type='submit'], +input[type='button'], +button { + border: none; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + border-radius: 20px; + padding: 8px 14px; + font-weight: 700; } + +#nolinks { + text-decoration: none; + color: var(--zc-text); +} + +.adminusername { + color: #dc2626; +} + +@media (max-width: 992px) { + .zc-layout { + grid-template-columns: 1fr; + padding: 14px; + } + + .zc-sidebar-left, + .zc-sidebar-right { + display: none; + } + + .zc-nav-links { + display: none; + } +} \ No newline at end of file From 29bed9ef8f210ea816d04eb6db4c757e20ced315 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 11 Apr 2026 13:16:00 -0400 Subject: [PATCH 60/68] remove git ignore --- .DS_Store | Bin 0 -> 6148 bytes forum/.DS_Store | Bin 0 -> 6148 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .DS_Store create mode 100644 forum/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..349fad4833640c03880823e48e8d3d8e1f29e378 GIT binary patch literal 6148 zcmeH~J&pn~427Q;kXG7;k}}O6fEz@JJpmU$fT%ztv7*n>_t|m7ur(T?XUTc76VK0A zOvV6gb3d(tC4f)5E53agnK54Ai~%>?aXtMWFNa~ccp4wM*8@7Q@wlGL5)lvq5fA|p z5P=C1h(ny`|JQ_`Nsl4|A}|dC{(UHP*P7b8#;1crv;fpO(_x%PFF`Gypw`sZl^L34 z_h4CS(S~?F%Bdyy)zsFtm&3C8u)MQ*7eljN4l4|3Rzoz1fC!8T%zC`^^Z$qbU;jTU zQ78f;@MZ*TzTIs%e5pKJpI*=N`^@^h(W$YW!^2Mi13!va^f0a$pHORR>&gsGKLUY4 Jg9yBoz#pf^6Py45 literal 0 HcmV?d00001 diff --git a/forum/.DS_Store b/forum/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5b80eae8dab05ca466a0e3827216b6df84715ffd GIT binary patch literal 6148 zcmeHKyG{c!5S)bwC89}5>0jUvPEqm&`~Z-oKspqpBmGr;7oW!L1BvLMNI?_LO6#%L zJGMN Date: Sat, 11 Apr 2026 13:33:17 -0400 Subject: [PATCH 61/68] layout.html updated to incorporate Bianca's frontend design, header.html updated to use her nav bar design, messages page style created to match what already exists, and style.css updated to address messing prompts. --- forum/templates/header.html | 21 +++++++------ forum/templates/layout.html | 56 +++++++++++++++++++---------------- forum/templates/messages.html | 17 +++++++---- 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/forum/templates/header.html b/forum/templates/header.html index a27387e..e852057 100644 --- a/forum/templates/header.html +++ b/forum/templates/header.html @@ -1,10 +1,13 @@ -{{ config.SITE_NAME }}{% if config.SITE_DESCRIPTION %} - {% endif %} {{ config.SITE_DESCRIPTION }} -