From e911a2cc3c344742445df9c6db777044ebb0e256 Mon Sep 17 00:00:00 2001 From: niciahrymer-hillian Date: Tue, 19 May 2026 08:53:18 -0400 Subject: [PATCH 01/10] Familiarized ourselves with the code and the request/response cycle, and fixed the hardcoded secret key --- CLAUDE.md | 218 ++++++++++++++++++++++++++++++++++++++ config.py | 5 +- references/WALKTHROUGH.md | 198 ++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 references/WALKTHROUGH.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1b5ad77 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,218 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +## 5. Act as a Partner, Not a Tool + +**Use natural conversation. Start broad, go deep together.** + +In our work together: +- Explain context and goals in natural, conversational language rather than just keywords. +- Start broad for ideation, then we'll go deeper on specific ideas to avoid generic results. +- Divide complex tasks into smaller, manageable, sequential prompts rather than one large, ambiguous request. +- Surface tradeoffs and ask clarifying questions before committing to a direction. +- Treat this as a collaborative partnership where we iterate and refine together. + +## 6. Act as a Teacher + +**Document the learning journey. Build a knowledge base as we go.** + +Throughout this project: +- Explain *how* and *why* solutions work, not just what they do. +- When you solve a problem or figure out a new approach, document the reasoning in comments or summary notes. +- For complex code or architecture decisions, and for any new or updated source file, maintain a complete annotated reference copy in `references/` (e.g., `filename_annotated_cp.py`) that fully mirrors the production file rather than summarizing it. +- Annotated reference copies must break down major sections with structural labels and decision explanations so they teach both what the code does and why it exists. +- Keep annotated references in sync immediately whenever production code changes, and use the `references/` folder consistently so it remains a reliable long-term learning system for revisiting past decisions. +- Surface patterns, gotchas, and lessons learned so future work builds on what we've already discovered. + +This isn't just about code—it's about building institutional knowledge that makes the next feature, bug fix, or refactor faster and more confident. + +## 7. Reference Documentation Governance + +**Each reference document must have one clear responsibility. Avoid overlap.** + +- Keep each reference focused on its purpose (for example: architecture, quickstart, testing, completion snapshot, conceptual notes). +- Do not duplicate large sections across multiple reference files. +- When overlap appears, keep the canonical content in one file and link to it from others. +- Update reference documents continuously as development progresses, not in large delayed batches. +- If a file is moved, update relative links so references remain navigable. +- Prefer concise, maintainable reference docs over long repeated walkthroughs. + +## 8. Testing Code is Essential + +**Write reliable tests. Test often, test early.** + +Testing is how we ensure software works as intended and catches regressions. + +### Structure and Scope + +- **AAA Pattern (Arrange, Act, Assert)**: Set up test data, run the code, assert the result. +- **One Thing Only**: Each unit test focuses on one tiny piece of functionality. +- **Independent**: Tests run alone or in any order without relying on other tests. +- **Isolate Dependencies**: Use mocks or stubs to isolate code from external factors (databases, APIs). + +### Testing Scenarios (What to Test) + +- **Positive Scenarios**: Test that code works with valid inputs. +- **Negative Scenarios**: Test that code fails gracefully with invalid inputs (expected exceptions). +- **Boundary Conditions**: Test edge cases (empty sets, max values, null inputs). +- **One-to-Many Relationships**: Test cascade deletes, foreign key constraints, and relationship integrity. + +### Test Quality and Maintenance + +- **Fast & Reliable**: Tests must run quickly; avoid flaky tests that fail randomly. +- **Descriptive Naming**: Name tests to explain what is being tested (e.g., `test_should_return_manufacturers_when_get_all_called`). +- **Treat as Production Code**: Write clean, readable, maintainable test code. +- **Avoid Logic in Tests**: Keep tests simple; no complex if/for loops in test bodies. + +### Workflow + +- **Test-Driven Development (TDD)**: Consider writing the test before the code to define the requirement. +- **Automate Everything**: Run tests automatically on every build and check-in. +- **Fix Immediately**: If a test fails, fix it immediately to maintain confidence in the suite. +- **Test Often**: Write tests for each new feature, endpoint, and edge case as you implement. + +### File Organization + +- Place all tests in the `tests/` folder with clear labels. +- Name test files following the pattern: `test_.py` (e.g., `test_manufacturers.py`, `test_products.py`). +- Create corresponding subdirectories: `tests/unit/`, `tests/integration/` as needed. +- Use `__init__.py` in test folders to make them packages. + +### What Not to Test (Unit Testing) + +- Do not test constants, properties, or simple wrappers unless they contain complex logic. +- Do not test private methods directly; test them through public methods. +- Focus on testing functionality, edge cases, and behavior. + +--- + +## 9. GitHub Issue and Sub-Issue Management + +**Keep the backlog clean. Promote checklist items to real sub-issues.** + +### Duplicate Detection and Cleanup + +When asked to audit or clean up GitHub issues: +1. Collect all open issue titles across every page: navigate to `/issues?state=open&per_page=100&page=N` for each page until pagination ends. +2. Build a map of `title → [issue numbers]` and flag any titles that appear more than once. +3. **GitHub does not hard-delete issues.** Close the duplicate (higher number), keeping the lower-numbered original open. +4. Report the closed issue number to the user so they can verify or hard-delete it manually if needed. + +### Converting Checklist Items to Real Sub-Issues + +When asked to convert checklist subtasks into actual GitHub sub-issues for an epic issue: + +1. Navigate to the epic issue page. +2. Read all remaining checklist items: `page.evaluate(() => Array.from(document.querySelectorAll('[data-testid^="tasklist-item-"] input[type="checkbox"]')).map(cb => cb.getAttribute('aria-label')?.replace(' checklist item', '')))`. +3. For each item, run this loop pattern: + ```js + // Hover to reveal the task options button + await page.getByRole('checkbox', { name: `${label} checklist item` }).first().hover(); + await page.waitForTimeout(600); + // Click the task options button + await page.getByRole('button', { name: `Open ${label} task options` }).click(); + await page.waitForTimeout(400); + // Convert to sub-issue + await page.getByRole('menuitem', { name: 'Convert to sub-issue' }).click(); + await page.waitForTimeout(1500); + ``` +4. If a single item fails (menu timeout), reload the page, re-query the remaining checklist items, and retry only the ones still present as checkboxes. +5. After all conversions, verify by re-checking that the checklist is empty: the `[data-testid^="tasklist-item-"]` query should return `[]`. + +### Best Practices + +- Process one epic at a time; reload the page between epics to get a clean DOM state. +- Sub-issues already converted disappear from the checklist automatically — the same label will not be found twice. +- After finishing all epics, scan all pages again for duplicates introduced during the batch conversion run. + +--- + +## 10. Kanban Card and GitHub Project Automation Prompts + +When managing project tasks, always clarify user intent before acting: + +### Kanban Card Conversion Flow + +1. **Prompt:** "Would you like me to convert these checklist tasks to Kanban cards?" + - If yes: Convert each checklist item into a main issue (epic) and sub-issue cards as appropriate. + - If no: Do not create cards; ask if the user wants a summary or export instead. + +2. **Prompt:** "Would you like me to upload these cards directly to your GitHub repo?" + - If yes: Ask for the GitHub repository URL (e.g., `https://github.com/owner/repo`). + - If no: Offer to export the cards as markdown, CSV, or another format. + +3. **Prompt:** If the site is blocked by sign-in (e.g., GitHub login required): + - Explain the authentication barrier to the user. + - Walk the user through the manual sign-in process: + - Instruct them to open the site in their browser and sign in. + - After sign-in, return to the automation flow. + - If automation is not possible, offer a manual step-by-step guide for creating cards/issues. + +### Reference for Future Automation +- Use this prompt sequence whenever converting tasks/checklists to Kanban or GitHub issues. +- Always confirm before uploading or modifying a remote repository. +- If blocked by authentication, pause and guide the user through sign-in, then resume. +- Document any manual steps taken for traceability. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, clarifying questions come before implementation rather than after mistakes, our conversation feels natural and goal-oriented, the references folder is a trusted learning resource, and tests catch bugs before production. diff --git a/config.py b/config.py index da78fea..195fe33 100644 --- a/config.py +++ b/config.py @@ -2,14 +2,15 @@ Flask configuration variables. """ from os import environ, path +from dotenv import load_dotenv basedir = path.abspath(path.dirname(__file__)) -# load_dotenv(path.join(basedir, '.env')) +load_dotenv(path.join(basedir, '.env')) class Config: """Set Flask configuration from .env file.""" # General Config - SECRET_KEY = 'kristofer' + SECRET_KEY = environ.get('SECRET_KEY') FLASK_APP = 'forum.app' # Database diff --git a/references/WALKTHROUGH.md b/references/WALKTHROUGH.md new file mode 100644 index 0000000..2e5332f --- /dev/null +++ b/references/WALKTHROUGH.md @@ -0,0 +1,198 @@ +# CircusCircus — Request/Response Cycle Walkthrough + +A guided tour of the codebase for new team members. Read this before writing any code. + +--- + +## The Black Box + +Flask's WSGI loop is the engine you never touch. It: +1. Listens on a port (5001) +2. Receives an HTTP request from the browser +3. Finds the matching route function in your code +4. Calls it, gets back a response +5. Sends that response to the browser + +**Your job as a developer is only to write the route functions.** Flask handles everything else. + +--- + +## Canonical Request/Response Cycle + +Use this exact mental model for every feature: + +``` +browser request + -> Flask route handler + -> SQLAlchemy query/create/update + -> render_template(...) with Jinja2 + -> HTML response sent back to browser +``` + +For one full page load: +1. Browser sends a GET request (for example, `/viewpost?post=3`). +2. Flask matches the URL to a route function in `routes.py`. +3. The route uses SQLAlchemy models in `models.py` to read/write data. +4. The route calls `render_template(...)` and passes Python objects to a Jinja2 template. +5. Jinja2 renders final HTML. +6. Flask returns that HTML response to the browser. + +Flask's WSGI loop is the black box around this flow; your code fills in the route handlers around it. + +--- + +## Layer 1 — Startup: `config.py` + `forum/__init__.py` + +When you run `flask run`, this happens **once**: + +``` +config.py → defines SECRET_KEY, DB path (sqlite), debug flags +forum/__init__.py → create_app() factory: + 1. creates the Flask object + 2. loads config.py settings + 3. registers the routes Blueprint (rt) + 4. attaches SQLAlchemy (db.init_app) + 5. calls db.create_all() → builds DB tables if missing +forum/app.py → seeds initial Subforum rows if DB is empty + sets up Flask-Login's user_loader callback + registers the index "/" route +``` + +After this, the app sits idle — waiting for a browser to knock. + +--- + +## Layer 2 — The Database: `models.py` (4 schemas) + +SQLAlchemy maps Python classes to database tables. Each class **is** a table: + +``` +User → users table + id, username, email, password_hash, admin + ↓ has many +Post → posts table + id, title, content, postdate, user_id (FK), subforum_id (FK) + ↓ has many +Comment → comments table + id, content, postdate, user_id (FK), post_id (FK) + +Subforum → subforum table + id, title, description, parent_id (FK → itself, for nesting) + ↓ has many Posts, has many child Subforums +``` + +**Relationships in plain English:** +- A `User` writes many `Post`s and many `Comment`s +- A `Post` belongs to one `Subforum` and one `User`, and has many `Comment`s +- A `Subforum` can have a parent `Subforum` (that's how "Forum → Announcements" nesting works) + +`models.py` also contains helper functions: +- `valid_title()` / `valid_content()` — enforce length rules before saving +- `generateLinkPath()` — builds the breadcrumb trail (e.g. Forum Index / Forum / Announcements) +- `error()` — returns a red HTML error string for simple inline errors + +--- + +## Layer 3 — Route Handlers: `routes.py` + `user.py` + +This is where a browser request meets your code. Every route follows the same pattern: + +``` +@rt.route('/path', methods=['GET'|'POST']) +def handler(): + 1. Read input (request.args for URL params, request.form for POST data) + 2. Query DB (Model.query.filter(...).first() or .all()) + 3. Do logic (validate, create objects, append relationships) + 4. Write DB (db.session.add(), db.session.commit()) + 5. Return (render_template(...) or redirect(...)) +``` + +### Route Map + +| Route | Method | What it does | +|---|---|---| +| `/` | GET | List all top-level subforums → `subforums.html` | +| `/subforum?sub=` | GET | Show one subforum + its posts → `subforum.html` | +| `/loginform` | GET | Show login/register form → `login.html` | +| `/action_login` | POST | Check username+password → redirect `/` or show errors | +| `/action_logout` | GET | Clear session → redirect `/` | +| `/action_createaccount` | POST | Validate + create `User` → redirect `/` | +| `/addpost?sub=` | GET | Show post creation form → `createpost.html` | +| `/action_post?sub=` | POST | Validate + save new `Post` → redirect `/viewpost` | +| `/viewpost?post=` | GET | Show one post + its comments → `viewpost.html` | +| `/action_comment?post=` | POST | Save new `Comment` → redirect `/viewpost` | + +`user.py` is a helper module — `username_taken()`, `email_taken()`, `valid_username()` — called by `action_createaccount`. + +--- + +## Layer 4 — Templates (the Front End) + +Templates are HTML files with **Jinja2** — Flask's templating language. +- `{{ variable }}` — outputs a value into the HTML +- `{% for x in list %}` / `{% if condition %}` — runs logic + +### Template Hierarchy + +``` +layout.html ← base shell: loads CSS, renders header, shows errors block + └─ header.html ← nav bar (included into layout on every page) + └─ subforums.html ← extends layout → loops over subforums, renders links + └─ subforum.html ← extends layout → shows one subforum + post list + └─ login.html ← extends layout → login form + register form + └─ createpost.html ← extends layout → title + content textarea form + └─ viewpost.html ← extends layout → post body + comment list + comment form +``` + +**No JavaScript logic** — all interactivity is server-side. The one JS snippet in +`viewpost.html` is just a `toggle()` call to show/hide the comment box. + +--- + +## A Complete Example: Posting a Comment + +``` +1. Browser visits /viewpost?post=3 + → Flask calls viewpost() in routes.py + → Queries Post WHERE id=3 + → Queries Comments WHERE post_id=3 + → render_template("viewpost.html", post=..., comments=...) + → Jinja2 fills in the HTML + → Browser receives the finished page + +2. User types a comment, clicks "Comment" + → Browser POSTs to /action_comment?post=3 + → Flask calls comment() in routes.py + → Reads request.form['content'] + → Creates Comment(content, now) + → current_user.comments.append(comment) ← links Comment to User in DB + → post.comments.append(comment) ← links Comment to Post in DB + → db.session.commit() ← writes to SQLite + → redirect("/viewpost?post=3") ← browser reloads page with new comment visible +``` + +--- + +## What's Front End vs Back End? + +| Front End | Back End | +|---|---| +| `templates/*.html` — page structure | `routes.py` — handles every request | +| `static/bootstrap.min.css` — Bootstrap styles | `models.py` — DB schema + queries | +| `static/style.css` — custom styles | `user.py` — validation helpers | +| Jinja2 `{{ }}` tags — fills in data | `app.py` — wires everything together | +| One small JS `toggle()` in viewpost | `config.py` — app settings | + +### Where is the Business Logic? + +- **`routes.py`** — input validation, auth checks, relationship wiring between models +- **`models.py`** — `get_time_string()` cache, `generateLinkPath()` breadcrumb builder, `valid_title/content()` rules + +--- + +## Known Issues to Fix (see GitHub Issues) + +- `SECRET_KEY = 'kristofer'` in `config.py` — must move to `.env` before any public push (security risk) +- `@login_required` is placed *above* `@rt.route(...)` on some routes — decorator order is wrong; correct order is `@rt.route(...)` first, then `@login_required` +- `routes.py` needs to be split into `auth.py`, `posts.py`, `comments.py` — see Epic 2 on GitHub +- Port 5000 is blocked on macOS by AirPlay Receiver — run on port 5001 instead diff --git a/requirements.txt b/requirements.txt index 39287bc..f564e08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Flask Flask-Login Flask-SQLAlchemy +python-dotenv gunicorn itsdangerous Jinja2 From bced1dabb12d6b7d6f469afd3c5934812af8762f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 12:53:31 +0000 Subject: [PATCH 02/10] Initial plan From 6c3e9ba2825b11c2a93f3585ee9b701bbe30f0b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 12:59:11 +0000 Subject: [PATCH 03/10] refactor: split routes into auth posts and comments blueprints Agent-Logs-Url: https://github.com/niciahrymer-hillian/CircusCircus/sessions/4953f5fc-3e8c-403c-a91a-d349498e6661 Co-authored-by: ShockaHolmes <78627489+ShockaHolmes@users.noreply.github.com> --- forum/__init__.py | 7 ++- forum/auth.py | 56 +++++++++++++++++ forum/comments.py | 25 ++++++++ forum/posts.py | 60 ++++++++++++++++++ forum/routes.py | 127 +-------------------------------------- tests/test_blueprints.py | 32 ++++++++++ 6 files changed, 182 insertions(+), 125 deletions(-) create mode 100644 forum/auth.py create mode 100644 forum/comments.py create mode 100644 forum/posts.py create mode 100644 tests/test_blueprints.py diff --git a/forum/__init__.py b/forum/__init__.py index c10b0f3..108291d 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,5 +1,8 @@ from flask import Flask from forum.routes import rt +from forum.auth import auth +from forum.posts import posts +from forum.comments import comments def create_app(): """Construct the core application.""" @@ -10,6 +13,9 @@ def create_app(): # subforum_routes # etc app.register_blueprint(rt) + app.register_blueprint(auth) + app.register_blueprint(posts) + app.register_blueprint(comments) # Set globals from forum.models import db db.init_app(app) @@ -18,4 +24,3 @@ def create_app(): # Add some routes db.create_all() return app - diff --git a/forum/auth.py b/forum/auth.py new file mode 100644 index 0000000..621bc87 --- /dev/null +++ b/forum/auth.py @@ -0,0 +1,56 @@ +from flask import Blueprint, render_template, request, redirect +from flask_login import login_user, logout_user +from flask_login.utils import login_required + +from forum.models import User, db +from forum.user import username_taken, email_taken, valid_username + +auth = Blueprint('auth', __name__, template_folder='templates') + + +@auth.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: + errors = [] + errors.append("Username or password is incorrect!") + return render_template("login.html", errors=errors) + return redirect("/") + + +@auth.route('/action_logout') +@login_required +def action_logout(): + logout_user() + return redirect("/") + + +@auth.route('/action_createaccount', methods=['POST']) +def action_createaccount(): + 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 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("/") diff --git a/forum/comments.py b/forum/comments.py new file mode 100644 index 0000000..0c92ad7 --- /dev/null +++ b/forum/comments.py @@ -0,0 +1,25 @@ +import datetime + +from flask import Blueprint, request, redirect +from flask_login import current_user +from flask_login.utils import login_required + +from forum.models import Comment, Post, db, error + +comments = Blueprint('comments', __name__, template_folder='templates') + + +@comments.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'] + postdate = datetime.datetime.now() + comment = Comment(content, postdate) + current_user.comments.append(comment) + post.comments.append(comment) + db.session.commit() + return redirect("/viewpost?post=" + str(post_id)) diff --git a/forum/posts.py b/forum/posts.py new file mode 100644 index 0000000..68fc247 --- /dev/null +++ b/forum/posts.py @@ -0,0 +1,60 @@ +import datetime + +from flask import Blueprint, render_template, request, redirect, url_for +from flask_login import current_user +from flask_login.utils import login_required + +from forum.models import Post, Subforum, Comment, valid_content, valid_title, db, generateLinkPath, error + +posts = Blueprint('posts', __name__, template_folder='templates') + + +@posts.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) + + +@posts.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 not post.subforum.path: + subforumpath = generateLinkPath(post.subforum.id) + comments = Comment.query.filter(Comment.post_id == postid).order_by(Comment.id.desc()) + return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + + +@posts.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("subforums")) + + 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(title, content, datetime.datetime.now()) + 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 75993e5..75fc8e1 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -1,10 +1,6 @@ -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 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 flask import Blueprint, render_template, request + +from forum.models import Post, Subforum, generateLinkPath, error ## # This file needs to be broken up into several, to make the project easier to work on. @@ -12,57 +8,6 @@ rt = Blueprint('routes', __name__, template_folder='templates') -@rt.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: - 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(): - #todo - logout_user() - return redirect("/") - -@rt.route('/action_createaccount', methods=['POST']) -def action_createaccount(): - 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') def subforum(): subforum_id = int(request.args.get("sub")) @@ -79,69 +24,3 @@ def subforum(): @rt.route('/loginform') def loginform(): return render_template("login.html") - - -@login_required -@rt.route('/addpost') -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) - -@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 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 - return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) - -@login_required -@rt.route('/action_comment', methods=['POST', 'GET']) -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'] - postdate = datetime.datetime.now() - comment = Comment(content, postdate) - current_user.comments.append(comment) - post.comments.append(comment) - db.session.commit() - return redirect("/viewpost?post=" + str(post_id)) - -@login_required -@rt.route('/action_post', methods=['POST']) -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("subforums")) - - user = current_user - title = request.form['title'] - content = request.form['content'] - #check for valid posting - 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(title, content, datetime.datetime.now()) - subforum.posts.append(post) - user.posts.append(post) - db.session.commit() - return redirect("/viewpost?post=" + str(post.id)) - diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py new file mode 100644 index 0000000..56f238b --- /dev/null +++ b/tests/test_blueprints.py @@ -0,0 +1,32 @@ +import unittest + +from forum import create_app + + +class BlueprintRegistrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = create_app() + + def test_expected_routes_are_registered(self): + rules = {rule.rule: rule.endpoint for rule in self.app.url_map.iter_rules()} + self.assertEqual(rules.get('/action_login'), 'auth.action_login') + self.assertEqual(rules.get('/action_logout'), 'auth.action_logout') + self.assertEqual(rules.get('/action_createaccount'), 'auth.action_createaccount') + self.assertEqual(rules.get('/addpost'), 'posts.addpost') + self.assertEqual(rules.get('/viewpost'), 'posts.viewpost') + self.assertEqual(rules.get('/action_post'), 'posts.action_post') + self.assertEqual(rules.get('/action_comment'), 'comments.comment') + self.assertEqual(rules.get('/subforum'), 'routes.subforum') + + def test_expected_methods_for_moved_routes(self): + rules = {rule.rule: rule for rule in self.app.url_map.iter_rules()} + self.assertIn('POST', rules['/action_login'].methods) + self.assertIn('POST', rules['/action_createaccount'].methods) + self.assertIn('POST', rules['/action_post'].methods) + self.assertIn('POST', rules['/action_comment'].methods) + self.assertIn('GET', rules['/action_comment'].methods) + + +if __name__ == '__main__': + unittest.main() From 0e3aeb10a4447c6ba2afe963d763423364b43fa2 Mon Sep 17 00:00:00 2001 From: ShockaHolmes Date: Tue, 19 May 2026 13:44:31 -0400 Subject: [PATCH 04/10] Updated Codewalkthrough README, --- CodeWalkthrough-Handout.md | 164 +++++++++++++++++ CodeWalkthrough.md | 298 +++++++++++++++++++++++++++++++ README.md | 41 ++++- SystemStartupModel.md | 357 +++++++++++++++++++++++++++++++++++++ run.sh | 10 +- 5 files changed, 863 insertions(+), 7 deletions(-) create mode 100644 CodeWalkthrough-Handout.md create mode 100644 CodeWalkthrough.md create mode 100644 SystemStartupModel.md diff --git a/CodeWalkthrough-Handout.md b/CodeWalkthrough-Handout.md new file mode 100644 index 0000000..00046e2 --- /dev/null +++ b/CodeWalkthrough-Handout.md @@ -0,0 +1,164 @@ +# CircusCircus Code Walkthrough Handout (1-Page) + +Use this sheet while reading the code together. + +## Goal + +By the end, everyone should be able to explain: + +- how the app starts, +- how requests are handled, +- how DB models connect, +- how routes map to templates. + +## Read Order (Follow Exactly) + +1. config.py +2. forum/__init__.py +3. forum/app.py +4. forum/models.py +5. forum/routes.py +6. templates in this order: + - layout.html + - header.html + - subforums.html + - subforum.html + - viewpost.html + - createpost.html + - login.html + +## What To Find In Each File + +## 1) config.py + +- SECRET_KEY +- SQLALCHEMY_DATABASE_URI +- FLASK_APP + +Ask: + +- Which values are dev-only? +- Which should come from environment variables? + +## 2) forum/__init__.py + +- create_app() +- blueprint registration +- db.init_app(app) + +Ask: + +- Where is app-wide wiring done? +- Where are routes attached? + +## 3) forum/app.py + +- app = create_app() +- login manager and user_loader +- seed logic: init_site() and add_subforum() +- index route: / + +Ask: + +- Why is seed logic running at startup? +- Should seeding be moved to a command? + +## 4) forum/models.py (4 Schemas) + +- User: auth fields + posts/comments relationships +- Post: title/content/date + user/subforum foreign keys +- Subforum: parent/child structure + posts +- Comment: content/date + user/post foreign keys + +Also review: + +- valid_title(), valid_content() +- generateLinkPath() + +Ask: + +- Are helpers in the best layer? +- Is breadcrumb HTML generation in the right place? + +## 5) forum/routes.py (HTTP Handlers) + +Auth/account: + +- POST /action_login +- GET /action_logout +- POST /action_createaccount +- GET /loginform + +Subforums/posts/comments: + +- GET /subforum +- GET /addpost +- POST /action_post +- GET /viewpost +- POST/GET /action_comment + +Ask: + +- Which routes mutate DB data? +- Which routes require login? +- Where is input validation done? + +## 6) Templates (Rendering Flow) + +Base: + +- layout.html wraps all pages +- header.html shows auth state and site info + +Pages: + +- subforums.html (home list) +- subforum.html (subforum + posts) +- viewpost.html (single post + comments) +- createpost.html (new post form) +- login.html (login + register) + +Route map: + +- / -> subforums.html +- /loginform -> login.html +- /subforum -> subforum.html +- /addpost -> createpost.html +- /viewpost -> viewpost.html + +## One Full Request/Response Trace + +Example: GET /subforum?sub=1 + +1. Browser sends HTTP request for /subforum?sub=1. +2. Flask's WSGI loop receives it and matches route to subforum() in routes.py. +3. Route runs SQLAlchemy queries: + - load selected subforum, + - load posts in that subforum, + - load child subforums. +4. Route calls render_template("subforum.html", ...) with context data. +5. Jinja2 renders subforum.html, which extends layout.html and includes header.html. +6. Flask returns rendered HTML in HTTP response. +7. Browser renders page and fetches static CSS. + +Black box vs your code: + +- Flask/WSGI black box: socket/request parsing, route dispatch internals, response transport. +- Your code: handler logic, ORM queries, validation, template selection, context values. + +## 30-Minute Fast Agenda + +1. 0-5 min: config + factory +2. 5-10 min: app.py startup + seed +3. 10-18 min: models and relationships +4. 18-25 min: routes and DB writes +5. 25-30 min: templates and final Q&A + +## End-of-Session Check + +Each person should answer all 4: + +1. Where does the app get config values? +2. Which route creates a post and where is validation? +3. How does a comment connect to both user and post? +4. Which template is the base wrapper for all pages? diff --git a/CodeWalkthrough.md b/CodeWalkthrough.md new file mode 100644 index 0000000..65bde15 --- /dev/null +++ b/CodeWalkthrough.md @@ -0,0 +1,298 @@ +# CircusCircus Group Code Walkthrough + +This guide is designed for a team read-through of the codebase. It follows request flow from config -> app startup -> models -> routes -> templates. + +## 1. Session Goal + +By the end of this walkthrough, everyone should be able to explain: + +- how the app boots, +- where data is stored, +- how each HTTP handler works, +- how template rendering maps to route data, +- where to make common changes safely. + +## 2. Quick Prep (5 minutes) + +1. Start the app from project root: + - source .venv/bin/activate + - ./run.sh +2. Open the app in browser at: + - http://127.0.0.1:5006 +3. Keep these files open side-by-side: + - config.py + - forum/__init__.py + - forum/app.py + - forum/models.py + - forum/routes.py + - forum/templates/* + +## 3. Architecture Map (2 minutes) + +High-level flow: + +1. Flask app config loads from Config in config.py. +2. App is created in forum/__init__.py via create_app(). +3. SQLAlchemy db object is defined in forum/models.py and initialized on app startup. +4. Extra startup work in forum/app.py creates seed subforums if DB is empty. +5. Requests hit route handlers in forum/routes.py and forum/app.py. +6. Routes render templates in forum/templates. + +## 3.1 One Full Request/Response Trace (Browser -> Route -> Query -> Template -> HTML) + +Use GET /subforum?sub=1 as the concrete example. + +1. Browser sends HTTP request + - Example request line: GET /subforum?sub=1 + - Flask's WSGI server receives this and dispatches it to your app. + +2. Flask route matching (your code starts here) + - Handler: subforum() in forum/routes.py + - The handler reads sub from request.args and loads the target subforum id. + +3. SQLAlchemy queries execute + - Query 1: find subforum by id. + - Query 2: load recent posts for that subforum. + - Query 3: load child subforums for nested navigation. + - Optional helper call: generateLinkPath(...) builds breadcrumb HTML. + +4. Route prepares template context + - Context keys passed to render_template: + - subforum + - posts + - subforums + - path + +5. Jinja2 renders HTML + - Template selected: subforum.html + - subforum.html extends layout.html + - layout.html includes header.html + - Jinja injects context values into template blocks and loops. + +6. Flask returns HTTP response + - render_template(...) returns a fully rendered HTML string. + - Flask wraps it as an HTTP 200 response and sends it back to the browser. + +7. Browser paints the page + - Browser parses returned HTML. + - It requests static CSS files referenced by layout.html. + - User sees the subforum page with posts and navigation. + +Where your team writes code vs Flask black box: + +- Flask/WSGI black box: + - socket listening, request parsing, route dispatch internals, response transport. +- Your code: + - route handlers, model queries, business rules, template selection, context shaping. + +Mini variant (home page): + +1. GET / +2. index() in forum/app.py +3. Subforum.query.filter(Subforum.parent_id == None).order_by(Subforum.id) +4. render_template("subforums.html", subforums=subforums) +5. HTML response rendered through layout.html + header.html + +## 4. File-by-File Walkthrough + +## 4.1 config.py (Configuration Source) + +What to read together: + +- Config class values: + - SECRET_KEY + - FLASK_APP + - SQLALCHEMY_DATABASE_URI + - SQLALCHEMY_ECHO + - SQLALCHEMY_TRACK_MODIFICATIONS + +Talk through: + +- This project is configured for SQLite by default. +- SECRET_KEY is hardcoded for local/dev use. +- Changing DB backend starts here (plus dependency + migration work). + +Group questions: + +- Should secrets move to environment variables? +- Do we want SQL echo enabled for debugging sessions? + +## 4.2 forum/__init__.py (Factory) + +What to read together: + +- create_app() +- app.register_blueprint(rt) +- db.init_app(app) +- db.create_all() inside app context + +Talk through: + +- The app factory pattern is present here. +- Routes are attached as a blueprint from routes.py. +- DB tables are created at startup via db.create_all(). + +Important note: + +- The factory lives in forum/__init__.py, while forum/app.py imports it and adds seed/setup behavior. + +## 4.3 forum/app.py (App Wiring + Seeding + Index Route) + +What to read together: + +- app = create_app() +- app.config SITE_NAME, SITE_DESCRIPTION, FLASK_DEBUG +- init_site() +- add_subforum(...) +- login_manager setup + user loader +- startup block under with app.app_context() +- index() route + +Talk through: + +- Seed logic runs once when no subforums exist. +- add_subforum avoids duplicate creation under same parent/root. +- login manager loads users by ID for session auth. +- Root page / renders top-level subforums. + +Group questions: + +- Should seeding be moved to a CLI command instead of startup? +- Should FLASK_DEBUG be environment-driven? + +## 4.4 forum/models.py (4 DB Schemas + Helpers) + +Read in this order: + +1. User +2. Post +3. Subforum +4. Comment + +Schema checklist: + +- User: + - id, username, password_hash, email, admin + - relationships: posts, comments + - password hashing via werkzeug + +- Post: + - id, title, content, user_id, subforum_id, postdate + - relationship: comments + - get_time_string() for relative timestamps + +- Subforum: + - id, title, description, parent_id, hidden + - relationships: subforums (self-referential), posts + +- Comment: + - id, content, postdate, user_id, post_id + - get_time_string() for relative timestamps + +Support helpers: + +- generateLinkPath(subforumid) builds breadcrumb HTML. +- valid_title(title) and valid_content(content) enforce post constraints. +- error(errormessage) returns inline HTML error message. + +Group questions: + +- Should breadcrumb creation return data instead of raw HTML strings? +- Should validation helpers live in a separate validators module? +- Should model methods avoid print/debug side effects? + +## 4.5 forum/routes.py (All HTTP Handlers) + +Walk handler-by-handler and classify each as auth/public and read/write. + +- POST /action_login + - Auth check, login_user, redirect home or rerender login with errors. + +- GET /action_logout (login required) + - logout_user and redirect home. + +- POST /action_createaccount + - Username/email validation, create user, auto-login, redirect home. + +- GET /subforum + - Reads subforum by query param sub, fetches child subforums + posts, renders subforum view. + +- GET /loginform + - Renders login/register page. + +- GET /addpost (login required) + - Loads subforum and renders create-post form. + +- GET /viewpost + - Loads post + comments, renders post details. + +- POST/GET /action_comment (login required) + - Creates comment for given post and current user, commits, redirects to post view. + +- POST /action_post (login required) + - Validates title/content, creates post, links to user and subforum, commits, redirects to viewpost. + +Cross-cutting things to discuss: + +- Query params are directly cast to int; malformed values can raise exceptions. +- Error handling is mostly manual and page-specific. +- Route module is monolithic and already marked for splitting. + +## 4.6 Templates (UI Flow) + +Read templates in render hierarchy order: + +1. layout.html +2. header.html +3. subforums.html +4. subforum.html +5. viewpost.html +6. createpost.html +7. login.html + +Template responsibilities: + +- layout.html + - Base shell, CSS includes, header include, global error display block, body slot. + +- header.html + - Site title/description, current auth state, login/logout links. + +- subforums.html + - Home page list of top-level subforums. + +- subforum.html + - Breadcrumb path, subforum metadata, child subforums, post list, create-post CTA. + +- viewpost.html + - Full post display, add-comment form toggle, comment list. + +- createpost.html + - Post creation form with title/content fields. + +- login.html + - Login form and registration form. + +Template-to-route map: + +- / -> subforums.html +- /loginform -> login.html +- /subforum -> subforum.html +- /addpost -> createpost.html +- /viewpost -> viewpost.html + +## 5. Suggested Group Reading Timeline (60 minutes) + +1. 0-10 min: config.py + factory in __init__.py +2. 10-20 min: app.py startup + seeding +3. 20-35 min: models.py schemas + relationships +4. 35-50 min: routes.py handlers +5. 50-60 min: template flow + UI route mapping + +## 6. Good First Refactors After Walkthrough + +1. Split routes.py into auth routes, post routes, subforum routes. +2. Move breadcrumb HTML generation out of models layer. +3. Add route-level input guards for missing/invalid query params. +4. Replace startup seeding with explicit CLI command. +5. Add tests for login, create account, create post, add comment. diff --git a/README.md b/README.md index cf8b121..11db992 100644 --- a/README.md +++ b/README.md @@ -32,19 +32,48 @@ On first run, the default subforums will be created. Although custom subforums a I had to make a bunch of changes in this code to get it running. Took far longer than it should. But now, if I have it right, you need to clone this and then -This currently puts a sqlite3 db in the /tmp directory. -(use atleast python 3.11) +This currently puts a sqlite3 db in the `instance/` directory. +(use at least python 3.11) + +## Run Locally + +1. Open a terminal and go to the project root. +2. Create and activate a virtual environment. +3. Install dependencies. +4. Start the app with `run.sh`. +5. Open the app in your browser. ``` -$ python3.11 -m venv venv -$ source venv/bin/activate +$ cd /Users/shocka/CircusCircus +$ python3.11 -m venv .venv +$ source .venv/bin/activate $ pip install -r requirements.txt $ ./run.sh ``` -and it should appear on port 5000 +The app runs on port 5006: + +`http://127.0.0.1:5006` + +## Troubleshooting + +- Port already in use + - Start on a different port: + - `cd forum && flask run --port=5007` + - Or find/stop the process using port 5006: + - `lsof -i :5006` + +- `flask` command not found + - Activate your virtual environment first: + - `source .venv/bin/activate` + +- Missing package/module errors + - Reinstall dependencies in the active venv: + - `pip install -r requirements.txt` -`http://0.0.0.0:5000` +- App not loading at `127.0.0.1:5006` + - Confirm the server is running and that you launched from the project root with: + - `./run.sh` ## Changes in 2023 diff --git a/SystemStartupModel.md b/SystemStartupModel.md new file mode 100644 index 0000000..08d959d --- /dev/null +++ b/SystemStartupModel.md @@ -0,0 +1,357 @@ +# CircusCircus Startup & Runtime Mental Model + +Use this to explain the system boot and request-handling loop to teammates. Reference actual code locations for deep dives. + +--- + +## Part 1: The Startup Sequence (Before Listening) + +### Phase 1: You run `./run.sh` from the terminal + +``` +$ ./run.sh +``` + +What happens: + +1. Shell script finds a free port (starting at 5006). +2. Sets environment: export SECRET_KEY="kristofer" +3. Calls: cd ./forum && flask run --port=5007 + +### Phase 2: Flask CLI bootstrap + +Flask's built-in CLI: + +1. Reads FLASK_APP env var (set in config.py). +2. Flask imports forum.app module. +3. Python executes top-level code in forum/app.py. + +**Key insight:** At this point, nothing is listening yet. We're still in startup/initialization. + +--- + +## Part 2: App Initialization (Code runs sequentially, once) + +### Step 1: Config loads (config.py) + +```python +class Config: + SECRET_KEY = 'kristofer' + SQLALCHEMY_DATABASE_URI = 'sqlite:///circuscircus.db' + SQLALCHEMY_ECHO = False + SQLALCHEMY_TRACK_MODIFICATIONS = False +``` + +Flask reads these into app.config dictionary. + +**File:** config.py (lines 1-19) + +### Step 2: App factory runs (forum/__init__.py → create_app()) + +```python +def create_app(): + app = Flask(__name__, instance_relative_config=False) + app.config.from_object('config.Config') + app.register_blueprint(rt) # attach routes + db.init_app(app) + with app.app_context(): + db.create_all() + return app +``` + +What happens: + +1. Flask app object created. +2. Config injected into app.config. +3. Routes blueprint registered (all handlers from routes.py attached). +4. SQLAlchemy db object initialized against app. +5. **Inside app context:** db.create_all() tells SQLAlchemy to create all table schemas if they don't exist. + +**File:** forum/__init__.py (lines 1-18) + +**What gets created in DB:** +- User table (id, username, password_hash, email, admin) +- Post table (id, title, content, user_id, subforum_id, postdate) +- Subforum table (id, title, description, parent_id, hidden) +- Comment table (id, content, postdate, user_id, post_id) + +### Step 3: Post-factory setup in forum/app.py + +```python +app = create_app() # <- Factory returns configured app + +app.config['SITE_NAME'] = 'Schooner' +app.config['SITE_DESCRIPTION'] = 'a schooner forum' +app.config['FLASK_DEBUG'] = 1 + +login_manager = LoginManager() +login_manager.init_app(app) + +@login_manager.user_loader +def load_user(userid): + return User.query.get(userid) + +with app.app_context(): + db.create_all() # Redundant but safe; creates tables if missing + if not Subforum.query.all(): + init_site() # Seed default subforums only on first run +``` + +What happens: + +1. Site-specific config values applied. +2. LoginManager wired up (Flask-Login auth framework). +3. user_loader callback registered (Flask knows how to load a User from session). +4. **Inside app context (second time):** + - Tables ensured to exist (again, safe). + - **If no subforums exist, seed them:** + - Create "Forum" parent subforum. + - Create children: "Announcements", "Bug Reports", "General Discussion", "Other". + - All inserted into DB and committed. + +**File:** forum/app.py (lines 1-42) + +### Step 4: Routes are now available + +At this point: + +- All route handlers from forum/routes.py are registered: + - POST /action_login + - GET /action_logout + - POST /action_createaccount + - GET /subforum + - GET /loginform + - GET /addpost + - POST /action_post + - GET /viewpost + - POST/GET /action_comment + - GET / (from app.py) + +**File:** forum/routes.py (all handlers) + +--- + +## Part 3: Flask Enters the Listening Loop (App Settles) + +### What Flask does next + +After initialization completes, Flask CLI calls: + +```python +app.run(host='127.0.0.1', port=5007, debug=False) +``` + +Flask now: + +1. **Binds a TCP socket** to 127.0.0.1:5007. +2. **Calls listen()** on the socket (OS kernel puts socket in listening state). +3. **Enters an event loop** (WSGI server's run loop). +4. **Prints:** + ``` + * Running on http://127.0.0.1:5007 + Press CTRL+C to quit + ``` +5. **Blocks forever** waiting for incoming TCP connections. + +This is the **listening phase**. The process is now idle, consuming minimal CPU, waiting for browser requests. + +--- + +## Part 4: A Request Arrives (Event Triggers Handler) + +### Scenario: User opens browser to http://127.0.0.1:5007/ + +Browser sends HTTP request: + +``` +GET / HTTP/1.1 +Host: 127.0.0.1:5007 +``` + +### What happens inside Flask's listening loop: + +1. **OS kernel receives TCP packet** on port 5007. +2. **Flask's WSGI server wakes up** (event notification from OS). +3. **Server reads HTTP request** from socket. +4. **WSGI server parses:** + - HTTP method: GET + - URL path: / + - Query string: (none) + - Headers: (all headers) +5. **Flask route dispatcher searches** for matching handler: + - Checks registered routes in order. + - Finds `@app.route('/')` in forum/app.py. +6. **Handler executes:** `index()` + ```python + @app.route('/') + def index(): + subforums = Subforum.query.filter(Subforum.parent_id == None).order_by(Subforum.id) + return render_template("subforums.html", subforums=subforums) + ``` +7. **Inside handler:** + - SQLAlchemy query runs against SQLite DB. + - Returns list of top-level subforums. + - render_template() loads subforums.html, extends layout.html, includes header.html. + - Jinja2 renders HTML string. +8. **Handler returns** HTML string to WSGI server. +9. **WSGI server wraps it** as HTTP response: + ``` + HTTP/1.1 200 OK + Content-Type: text/html + Content-Length: ... + + ...rendered page... + ``` +10. **Response sent** back to browser over socket. +11. **Socket connection closes**. +12. **Flask server goes back to sleep** waiting for next request. + +**This loop repeats** for every request until you press CTRL+C. + +--- + +## Part 5: Steady State (Listening Loop Diagram) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ FLASK LISTENING LOOP (WSGI Server) │ +│ │ +│ while True: │ +│ connection = socket.accept() # <- BLOCKS HERE │ +│ │ +│ request = parse_http(connection) │ +│ route_handler = match_route(request.path) │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ HANDLER EXECUTION (YOUR CODE) │ │ +│ │ - Query DB via SQLAlchemy │ │ +│ │ - Validate input │ │ +│ │ - Modify DB (commits done here) │ │ +│ │ - Render template │ │ +│ │ - Return HTML/redirect │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ response = wrap_in_http(handler_result) │ +│ connection.send(response) │ +│ connection.close() │ +│ │ +│ # Loop continues; blocks at socket.accept() again │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +Key insight: + +- **Socket.accept() blocks** the entire process. +- OS kernel wakes process when a connection arrives. +- Handler runs, response sent, socket closes. +- Process goes back to sleep. + +--- + +## Part 6: DB Persistence Across Requests + +SQLite DB file: `instance/circuscircus.db` + +1. **First run:** db.create_all() creates file and schema. +2. **Every request:** SQLAlchemy connection pool manages DB connections. +3. **On write (POST /action_post, etc.):** db.session.commit() persists to disk. +4. **Process exits:** DB file remains on disk for next run. + +**No separate DB server needed.** SQLite is a file-based DB that runs in the same process. + +--- + +## Part 7: Shutdown (Press CTRL+C) + +You press CTRL+C in terminal. + +``` +OS sends SIGINT signal to process +↓ +Flask receives signal +↓ +Flask stops accepting connections on socket +↓ +Flask closes socket +↓ +Flask exits main loop +↓ +Process terminates +↓ +Database file remains intact on disk +``` + +--- + +## Whiteboard Mental Model Summary + +Draw this on a whiteboard in layers: + +``` +┌───────────────────────────────────────────────────────────────┐ +│ TERMINAL: $ ./run.sh │ +└───────────────────────────────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────────────────────────────┐ +│ STARTUP PHASE (one-time, sequential) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 1. Config loads (config.py) │ │ +│ │ 2. App factory creates app (forum/__init__.py) │ │ +│ │ 3. DB tables created if missing (db.create_all) │ │ +│ │ 4. Post-factory setup (forum/app.py) │ │ +│ │ 5. Seed subforums if DB empty (init_site) │ │ +│ │ 6. Routes registered (from forum/routes.py) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────────────────────────────┐ +│ LISTENING PHASE (runs forever until CTRL+C) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ App listening on http://127.0.0.1:5007 │ │ +│ │ │ │ +│ │ for each request: │ │ +│ │ 1. Socket receives TCP connection from browser │ │ +│ │ 2. HTTP request parsed │ │ +│ │ 3. Route handler matched │ │ +│ │ 4. Handler executes (YOUR CODE) │ │ +│ │ - Queries DB │ │ +│ │ - Validates, modifies │ │ +│ │ - Renders template │ │ +│ │ 5. HTML response sent to browser │ │ +│ │ 6. Socket closes │ │ +│ │ 7. Loop returns to listening │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────────────────────────────┐ +│ SHUTDOWN (CTRL+C) │ +│ - DB file closed │ +│ - Process exits │ +│ - Data persists in instance/circuscircus.db │ +└───────────────────────────────────────────────────────────────┘ +``` + +--- + +## Quick Reference: File Responsibilities + +| File | Purpose | +|------|---------| +| config.py | Database URI, secret key, Flask config | +| forum/__init__.py | App factory: config loading, DB init, blueprint registration | +| forum/app.py | Post-factory: LoginManager, seed logic, / route | +| forum/models.py | SQLAlchemy schemas (User, Post, Subforum, Comment) | +| forum/routes.py | HTTP handler functions (all routes except /) | +| forum/templates/* | Jinja2 templates rendered by handlers | +| instance/circuscircus.db | SQLite database file (persists data) | + +--- + +## Key Takeaways for Whiteboard + +1. **Startup is one-time, sequential:** Config → Factory → Seed → Listen. +2. **Listening is an event loop:** Blocks on socket, wakes on request, executes handler, sleeps again. +3. **Your code lives in handlers:** Every handler gets one chance to query, validate, render, return. +4. **DB persists across restarts:** SQLite file stays on disk; db.create_all() is safe to call repeatedly. +5. **Flask WSGI is the black box:** Socket binding, request parsing, response transport. You fill in the handler logic. diff --git a/run.sh b/run.sh index a39697a..189d2de 100755 --- a/run.sh +++ b/run.sh @@ -2,5 +2,13 @@ export SECRET_KEY="kristofer" # honcho start +# Choose an open port starting at 5006 to avoid startup failures. +PORT="${PORT:-5006}" +while lsof -i TCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; do + PORT=$((PORT + 1)) +done + +echo "Starting Flask on port $PORT" + # you can ALSO or RATHER use the following command to run the app -cd ./forum; flask run +cd ./forum && flask run --port="$PORT" From 36466b22ca4d8a5b74544e519e410f3a8bb3f85b Mon Sep 17 00:00:00 2001 From: ShockaHolmes Date: Wed, 20 May 2026 14:04:04 -0400 Subject: [PATCH 05/10] Create Comments System. Tested that it works. --- forum/routes.py | 13 +++++++++---- forum/templates/viewpost.html | 32 ++++++++++++++------------------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/forum/routes.py b/forum/routes.py index 75993e5..6bcccf3 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -97,19 +97,24 @@ def viewpost(): post = Post.query.filter(Post.id == postid).first() if not post: return error("That post does not exist!") - if not post.subforum.path: + comment_error = request.args.get("comment_error") + subforumpath = post.subforum.path + if not subforumpath: subforumpath = generateLinkPath(post.subforum.id) + post.subforum.path = subforumpath comments = Comment.query.filter(Comment.post_id == postid).order_by(Comment.id.desc()) # no need for scalability now - return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + return render_template("viewpost.html", post=post, path=subforumpath, comments=comments, comment_error=comment_error) @login_required -@rt.route('/action_comment', methods=['POST', 'GET']) +@rt.route('/action_comment', methods=['POST']) 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'] + content = request.form.get('content', '').strip() + if len(content) < 1: + return redirect("/viewpost?post=" + str(post_id) + "&comment_error=empty") postdate = datetime.datetime.now() comment = Comment(content, postdate) current_user.comments.append(comment) diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 3f489ca..176285a 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -18,41 +18,37 @@ -
-
-
- -
-
- - - {% if current_user.is_authenticated %} + {% if comment_error == 'empty' %} +
Comment cannot be empty.
+ {% endif %} +
+
+
+ +
+
{% else %} Login or register to make a comment {% endif %}
-{%if comments%} +{% if comments.first() %}
{% for comment in comments %} -
- ({{ comment.user.username }}) - + {{ comment.user.username }}
-
- {{ comment.content }} -
-
{{ comment.get_time_string() }}
+
+ {{ comment.content }} +
-
- {% endfor %}
{% endif %} From f90f9b6e3cc5f0583dac6ea3f10daa4427352848 Mon Sep 17 00:00:00 2001 From: ShockaHolmes Date: Wed, 20 May 2026 14:50:30 -0400 Subject: [PATCH 06/10] Added Emoji system --- forum/models.py | 15 +++++++ forum/routes.py | 83 ++++++++++++++++++++++++++++++++--- forum/static/style.css | 45 +++++++++++++++++++ forum/templates/subforum.html | 23 +++++++++- forum/templates/viewpost.html | 21 +++++++++ 5 files changed, 181 insertions(+), 6 deletions(-) diff --git a/forum/models.py b/forum/models.py index 8add9ae..7fedd2f 100644 --- a/forum/models.py +++ b/forum/models.py @@ -17,6 +17,7 @@ class User(UserMixin, db.Model): admin = db.Column(db.Boolean, default=False) posts = db.relationship("Post", backref="user") comments = db.relationship("Comment", backref="user") + reactions = db.relationship("Reaction", backref="user") def __init__(self, email, username, password): self.email = email @@ -30,6 +31,7 @@ class Post(db.Model): title = db.Column(db.Text) content = db.Column(db.Text) comments = db.relationship("Comment", backref="post") + reactions = db.relationship("Reaction", 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) @@ -115,6 +117,19 @@ def get_time_string(self): self.savedresponce = "Just a moment ago!" return self.savedresponce +class Reaction(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False) + reaction_type = db.Column(db.String(16), nullable=False) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'post_id', name='uq_reaction_user_post'), + ) + + def __init__(self, reaction_type): + self.reaction_type = reaction_type + def error(errormessage): return "" + errormessage + "" diff --git a/forum/routes.py b/forum/routes.py index 6bcccf3..61cb6f8 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -2,8 +2,9 @@ from flask_login import current_user, login_user, logout_user from flask_login.utils import login_required import datetime +from sqlalchemy import func 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.models import User, Post, Comment, Subforum, Reaction, valid_content, valid_title, db, generateLinkPath, error from forum.user import username_taken, email_taken, valid_username ## @@ -11,6 +12,41 @@ ## rt = Blueprint('routes', __name__, template_folder='templates') +ALLOWED_REACTIONS = {"like", "dislike", "heart"} + +def get_reaction_data(post_ids): + counts_by_post = {} + for post_id in post_ids: + counts_by_post[post_id] = {"like": 0, "dislike": 0, "heart": 0} + + if not post_ids: + return counts_by_post, {} + + counts = db.session.query( + Reaction.post_id, + Reaction.reaction_type, + func.count(Reaction.id) + ).filter( + Reaction.post_id.in_(post_ids) + ).group_by( + Reaction.post_id, + Reaction.reaction_type + ).all() + + for post_id, reaction_type, total in counts: + if reaction_type in ALLOWED_REACTIONS: + counts_by_post[post_id][reaction_type] = total + + user_reactions = {} + if current_user.is_authenticated: + reactions = Reaction.query.filter( + Reaction.user_id == current_user.id, + Reaction.post_id.in_(post_ids) + ).all() + for reaction in reactions: + user_reactions[reaction.post_id] = reaction.reaction_type + + return counts_by_post, user_reactions @rt.route('/action_login', methods=['POST']) def action_login(): @@ -69,12 +105,16 @@ 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) - if not subforum.path: + posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50).all() + subforumpath = subforum.path + if not subforumpath: subforumpath = generateLinkPath(subforum.id) + subforum.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) + post_ids = [post.id for post in posts] + reaction_counts, user_reactions = get_reaction_data(post_ids) + return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath, reaction_counts=reaction_counts, user_reactions=user_reactions, current_path="/subforum?sub=" + str(subforum.id)) @rt.route('/loginform') def loginform(): @@ -103,7 +143,40 @@ def viewpost(): subforumpath = generateLinkPath(post.subforum.id) post.subforum.path = subforumpath comments = Comment.query.filter(Comment.post_id == postid).order_by(Comment.id.desc()) # no need for scalability now - return render_template("viewpost.html", post=post, path=subforumpath, comments=comments, comment_error=comment_error) + reaction_counts, user_reactions = get_reaction_data([post.id]) + return render_template("viewpost.html", post=post, path=subforumpath, comments=comments, comment_error=comment_error, post_reaction_counts=reaction_counts.get(post.id), user_reaction=user_reactions.get(post.id), current_path="/viewpost?post=" + str(post.id)) + +@login_required +@rt.route('/action_react', methods=['POST']) +def action_react(): + post_id = int(request.args.get("post")) + reaction_type = request.args.get("type", "").strip().lower() + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + if reaction_type not in ALLOWED_REACTIONS: + return error("Invalid reaction type!") + + reaction = Reaction.query.filter( + Reaction.user_id == current_user.id, + Reaction.post_id == post.id + ).first() + + if not reaction: + reaction = Reaction(reaction_type) + reaction.user_id = current_user.id + reaction.post_id = post.id + db.session.add(reaction) + elif reaction.reaction_type == reaction_type: + db.session.delete(reaction) + else: + reaction.reaction_type = reaction_type + + db.session.commit() + redirect_target = request.form.get("next", "").strip() + if not redirect_target.startswith("/"): + redirect_target = "/viewpost?post=" + str(post.id) + return redirect(redirect_target) @login_required @rt.route('/action_comment', methods=['POST']) diff --git a/forum/static/style.css b/forum/static/style.css index 2dd2e2e..c5483af 100644 --- a/forum/static/style.css +++ b/forum/static/style.css @@ -189,3 +189,48 @@ .varwidth{ width: 50%; } + +.reactions{ + margin-top: 0.5%; + font-size: 11pt; +} + +.reactionform{ + display: inline; +} + +.reactionbutton{ + background: #f5f5f5; + border: 1px solid gray; + border-radius: 12px; + padding: 0.2em 0.6em; + margin-right: 0.4em; + cursor: pointer; + font-size: 10pt; +} + +.reactionbutton:hover{ + background: #ececec; +} + +.active-reaction{ + border-color: #2059d6; + background: #e9f0ff; + font-weight: bold; +} + +.reactioncount{ + display: inline-block; + border: 1px solid #d5d5d5; + border-radius: 12px; + padding: 0.2em 0.6em; + margin-right: 0.4em; + background: #fafafa; +} + +.reactionhint{ + margin-top: 0.4em; + font-size: 10pt; + font-style: italic; + color: #444; +} diff --git a/forum/templates/subforum.html b/forum/templates/subforum.html index 8783cd1..17f6c81 100644 --- a/forum/templates/subforum.html +++ b/forum/templates/subforum.html @@ -40,7 +40,7 @@ -{% if posts.first() %} +{% if posts %} {% for post in posts %}
@@ -52,6 +52,27 @@ {{ post.get_time_string() }}
+
+ {% if current_user.is_authenticated %} +
+ + +
+
+ + +
+
+ + +
+
Click again to remove your reaction.
+ {% else %} + 👍 {{ reaction_counts[post.id]['like'] }} + 👎 {{ reaction_counts[post.id]['dislike'] }} + ❤️ {{ reaction_counts[post.id]['heart'] }} + {% endif %} +
{% endfor %} diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 176285a..6291eaa 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -16,6 +16,27 @@
{{post.content}}
+
+ {% if current_user.is_authenticated %} +
+ + +
+
+ + +
+
+ + +
+
Click again to remove your reaction.
+ {% else %} + 👍 {{ post_reaction_counts['like'] }} + 👎 {{ post_reaction_counts['dislike'] }} + ❤️ {{ post_reaction_counts['heart'] }} + {% endif %} +
From 338a2deb65bb90919f635203688ffdb0ea28e9c8 Mon Sep 17 00:00:00 2001 From: niciahrymer-hillian Date: Thu, 21 May 2026 13:13:05 -0400 Subject: [PATCH 07/10] Switch from Heroku to Docker: add docker-compose, fix Gunicorn binding, add healthcheck, update requirements --- Docker.md | 153 ++++++++++++++ Dockerfile | 28 +++ Heroku.md | 32 --- config.py | 9 +- docker-compose.yml | 40 ++++ references/DockerInstall_macOS.md | 14 ++ references/Dockerfile_annotated_cp.py | 31 +++ references/WALKTHROUGH.md | 199 ++++++++++++++++++ references/config_annotated_cp.py | 21 ++ .../docker-compose.yml_annotated_cp.yaml | 27 +++ references/dotenv_annotated_cp.env | 4 + requirements.txt | 28 ++- 12 files changed, 541 insertions(+), 45 deletions(-) create mode 100644 Docker.md create mode 100644 Dockerfile delete mode 100644 Heroku.md create mode 100644 docker-compose.yml create mode 100644 references/DockerInstall_macOS.md create mode 100644 references/Dockerfile_annotated_cp.py create mode 100644 references/WALKTHROUGH.md create mode 100644 references/config_annotated_cp.py create mode 100644 references/docker-compose.yml_annotated_cp.yaml create mode 100644 references/dotenv_annotated_cp.env diff --git a/Docker.md b/Docker.md new file mode 100644 index 0000000..338e2de --- /dev/null +++ b/Docker.md @@ -0,0 +1,153 @@ +## Deploying CircusCircus with Docker & PostgreSQL + +Heroku is no longer used. Follow these steps for local and production deployment using Docker: + +## 1. Install Docker Desktop (macOS) + +If you do not have Docker installed: +1. Download Docker Desktop from https://www.docker.com/products/docker-desktop/ +2. Open the .dmg and drag Docker to Applications. +3. Launch Docker Desktop and wait for the whale icon in the menu bar. +4. Open a terminal and run `docker --version` to verify installation. + +See `references/DockerInstall_macOS.md` for more details. + +## 2. Start PostgreSQL with Docker + +Run the following command (replace `` with a secure password, e.g., `Elephant`): + +``` +docker run --name circuscircus-db -e POSTGRES_USER=ccuser -e POSTGRES_PASSWORD= -e POSTGRES_DB=circuscircus -p 5432:5432 -d postgres +``` + +## 3. Install Python dependencies + +For local development, install `psycopg2-binary`: +``` +pip install psycopg2-binary +``` +For production (in Docker), use `psycopg2` in your requirements.txt. + +## 4. Update config.py for PostgreSQL + +Edit `config.py` to use the following URI: +``` +postgresql://ccuser:@db/circuscircus +``` +Load credentials from `.env` and set the host to `db` (the Docker service name). + +## 5. Run the app with Docker Compose + +Use `docker-compose.yml` to define both the web and db services. Example: +``` +version: '3.8' +services: + db: + image: postgres + environment: + POSTGRES_USER: ccuser + POSTGRES_PASSWORD: + POSTGRES_DB: circuscircus + ports: + - "5432:5432" + web: + build: . + command: gunicorn forum.app:app + depends_on: + - db + environment: + - DATABASE_URL=postgresql://ccuser:@db/circuscircus + ports: + - "8000:8000" +``` + +## 6. Set up environment variables + +Add `DATABASE_URL` to your `.env` file and reference it in `docker-compose.yml`. + +## 7. Test and Migrate Database + +After running `docker compose up`, exec into the app container: +``` +docker exec -it bash +``` +Then run your migration/init scripts (e.g., `db.create_all()`, seed data, etc.). + +## 8. Push to Production + +Build and push your image: +``` +docker build -t circuscircus . +docker push /circuscircus +``` +Deploy using your host (Railway, Render, Fly.io, etc.) and smoke test the live URL. + +--- + +**Note:** +- Do NOT use 'whatever' as your database password. +- For more details, see the `references/DockerInstall_macOS.md` file. +pip install psycopg2-binary +``` +For container builds, use `psycopg2` in requirements.txt. + +## 3. Update config.py + +Set the SQLAlchemy URI to: +``` +postgresql://ccuser:@db/circuscircus +``` +Load credentials from `.env`. The host should be `db` (Docker service name), not `localhost`. + +## 4. Add .env file + +Create a `.env` file with: +``` +DATABASE_URL=postgresql://ccuser:@db/circuscircus +``` + +## 5. Docker Compose + +Create or update `docker-compose.yml`: +```yaml +version: '3.8' +services: + db: + image: postgres + environment: + POSTGRES_USER: ccuser + POSTGRES_PASSWORD: + POSTGRES_DB: circuscircus + ports: + - "5432:5432" + web: + build: . + command: gunicorn forum.app:app + environment: + DATABASE_URL: postgresql://ccuser:@db/circuscircus + depends_on: + - db + ports: + - "8000:8000" +``` + +## 6. Test the app + +Run: +``` +docker compose up +``` +Then exec into the app container: +``` +docker exec -it bash +``` +Run `db.create_all()`, seed subforums, and test CRUD for User, Post, Comment, and Subforum. + +## 7. Deploy + +Build and push your image: +``` +docker build -t circuscircus . +docker push /circuscircus +``` +Deploy to your host (Railway, Render, Fly.io, etc.) and smoke test the live URL. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35aac58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# [BASE] Use official Python image for ARM (Apple Silicon) + +# Use python:3.11-slim as base +FROM python:3.11-slim + +# Install build dependencies for psycopg2 +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libpq-dev build-essential \ + && rm -rf /var/lib/apt/lists/* + +# [WORKDIR] Set working directory +WORKDIR /app + +# [COPY] Copy requirements and install + +# Copy requirements and install +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + + +# Copy project files +COPY . . + +# [EXPOSE] Expose port for gunicorn +EXPOSE 8000 + +# [CMD] Run gunicorn +CMD ["gunicorn", "forum.app:app", "--bind", "0.0.0.0:8000"] diff --git a/Heroku.md b/Heroku.md deleted file mode 100644 index e50e01b..0000000 --- a/Heroku.md +++ /dev/null @@ -1,32 +0,0 @@ -# Run on Heroku.com - -- get a Heroku account -- create a new heroku app -- download the heroku CLI -- login from terminal (where you will run the app) -- `git push heroku main` - -other things.... - -heroku git:remote -a circuscircus -heroku addons:create heroku-postgresql:hobby-dev -a circuscircus - -## Changes in 2021 - -If you want to run postgresql on your local machine to test first. - -added `heroku` branch, and the postgres support. - -**NOTA BENE:** DO NOT USE _whatever_ as your database password. Pick something else. -``` -CREATE USER ccuser WITH PASSWORD 'whatever'; -ALTER ROLE ccuser SET client_encoding TO 'utf8'; -ALTER ROLE ccuser SET default_transaction_isolation TO 'read committed'; -ALTER ROLE ccuser SET timezone TO 'UTC'; - -CREATE DATABASE circuscircus; -GRANT ALL PRIVILEGES ON DATABASE circuscircus TO ccuser; -``` -(Remember, this stuff is ALL done ot the postgres running on your DEV box, not the app on the internet at Heroku) - -edit the forum.py file and switch dbs \ No newline at end of file diff --git a/config.py b/config.py index da78fea..c85c552 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,12 @@ """ Flask configuration variables. """ + from os import environ, path +from dotenv import load_dotenv basedir = path.abspath(path.dirname(__file__)) -# load_dotenv(path.join(basedir, '.env')) +load_dotenv(path.join(basedir, '.env')) class Config: """Set Flask configuration from .env file.""" @@ -13,6 +15,9 @@ class Config: FLASK_APP = 'forum.app' # Database - SQLALCHEMY_DATABASE_URI = 'sqlite:///circuscircus.db' + # Use DATABASE_URL from .env, fallback to SQLite for local dev + # For Docker/PostgreSQL: postgresql://ccuser:@db/circuscircus + # Note: host must be 'db' (Docker service name), not 'localhost' + SQLALCHEMY_DATABASE_URI = environ.get('DATABASE_URL', 'sqlite:///circuscircus.db') SQLALCHEMY_ECHO = False SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a4c5b80 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ + +services: + db: + image: postgres + environment: + POSTGRES_USER: ccuser + POSTGRES_PASSWORD: Elephant + POSTGRES_DB: circuscircus + ports: + - "5432:5432" + volumes: + - circuscircus-db-data:/var/lib/postgresql + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ccuser -d circuscircus"] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: . + command: gunicorn forum.app:app --bind 0.0.0.0:8000 + env_file: + - .env + environment: + DATABASE_URL: postgresql://ccuser:Elephant@db/circuscircus + depends_on: + db: + condition: service_healthy + ports: + - "8000:8000" + networks: + - app-network + +networks: + app-network: + +volumes: + circuscircus-db-data: \ No newline at end of file diff --git a/references/DockerInstall_macOS.md b/references/DockerInstall_macOS.md new file mode 100644 index 0000000..609d458 --- /dev/null +++ b/references/DockerInstall_macOS.md @@ -0,0 +1,14 @@ +# Installing Docker Desktop on macOS + +1. Go to the official Docker Desktop download page: https://www.docker.com/products/docker-desktop/ +2. Download the Docker Desktop for Mac installer (choose Apple Silicon or Intel based on your Mac). +3. Open the downloaded `.dmg` file and drag Docker to your Applications folder. +4. Launch Docker Desktop from Applications. Grant any requested permissions. +5. Wait for Docker to start (the whale icon appears in the menu bar). +6. Open a terminal and run `docker --version` to verify installation. + +**Troubleshooting:** +- If you see 'command not found: docker', restart your terminal or log out and back in. +- For more help, see: https://docs.docker.com/desktop/install/mac-install/ + +Once Docker is running, you can proceed with the PostgreSQL setup and other Docker-based development tasks. \ No newline at end of file diff --git a/references/Dockerfile_annotated_cp.py b/references/Dockerfile_annotated_cp.py new file mode 100644 index 0000000..571b0ee --- /dev/null +++ b/references/Dockerfile_annotated_cp.py @@ -0,0 +1,31 @@ + +# [BASE] Use official Python image for ARM (Apple Silicon) +FROM python:3.11-slim +# [WHY] Ensures compatibility and small image size for M1/M2 Macs and production + +# [BUILD-ESSENTIAL] Install build tools and PostgreSQL client libraries for psycopg2 +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libpq-dev build-essential \ + && rm -rf /var/lib/apt/lists/* +# [WHY] Required for building psycopg2 from source in Docker + +# [WORKDIR] Set working directory +WORKDIR /app +# [EFFECT] All subsequent commands run from /app + +# [COPY] Copy requirements and install +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +# [WHY] Installs all Python dependencies for the app + +# [COPY] Copy project files +COPY . . +# [EFFECT] All app code and resources are available in the container + +# [EXPOSE] Expose port for gunicorn +EXPOSE 8000 +# [EFFECT] Makes port 8000 available for Docker networking + +# [CMD] Run gunicorn +CMD ["gunicorn", "forum.app:app", "--bind", "0.0.0.0:8000"] +# [WHY] Launches the Flask app using gunicorn for production diff --git a/references/WALKTHROUGH.md b/references/WALKTHROUGH.md new file mode 100644 index 0000000..a9afe3e --- /dev/null +++ b/references/WALKTHROUGH.md @@ -0,0 +1,199 @@ +# CircusCircus — Request/Response Cycle Walkthrough + +A guided tour of the codebase for new team members. Read this before writing any code. + +--- + +## The Black Box + +Flask's WSGI loop is the engine you never touch. It: +1. Listens on a port (5001) +2. Receives an HTTP request from the browser +3. Finds the matching route function in your code +4. Calls it, gets back a response +5. Sends that response to the browser + +**Your job as a developer is only to write the route functions.** Flask handles everything else. + +--- + +## Canonical Request/Response Cycle + +Use this exact mental model for every feature: + +``` +browser request + -> Flask route handler + -> SQLAlchemy query/create/update + -> render_template(...) with Jinja2 + -> HTML response sent back to browser +``` + +For one full page load: +1. Browser sends a GET request (for example, `/viewpost?post=3`). +2. Flask matches the URL to a route function in `routes.py`. +3. The route uses SQLAlchemy models in `models.py` to read/write data. +4. The route calls `render_template(...)` and passes Python objects to a Jinja2 template. +5. Jinja2 renders final HTML. +6. Flask returns that HTML response to the browser. + +Flask's WSGI loop is the black box around this flow; your code fills in the route handlers around it. + +--- + +## Layer 1 — Startup: `config.py` + `forum/__init__.py` + +When you run `flask run`, this happens **once**: + +``` +config.py → defines SECRET_KEY, DB path (sqlite), debug flags +forum/__init__.py → create_app() factory: + 1. creates the Flask object + 2. loads config.py settings + 3. registers the routes Blueprint (rt) + 4. attaches SQLAlchemy (db.init_app) + 5. calls db.create_all() → builds DB tables if missing +forum/app.py → seeds initial Subforum rows if DB is empty + sets up Flask-Login's user_loader callback + registers the index "/" route +``` + +After this, the app sits idle — waiting for a browser to knock. + +--- + +## Layer 2 — The Database: `models.py` (4 schemas) + +SQLAlchemy maps Python classes to database tables. Each class **is** a table: + +``` +User → users table + id, username, email, password_hash, admin + ↓ has many +Post → posts table + id, title, content, postdate, user_id (FK), subforum_id (FK) + ↓ has many +Comment → comments table + id, content, postdate, user_id (FK), post_id (FK) + +Subforum → subforum table + id, title, description, parent_id (FK → itself, for nesting) + ↓ has many Posts, has many child Subforums +``` + +**Relationships in plain English:** +- A `User` writes many `Post`s and many `Comment`s +- A `Post` belongs to one `Subforum` and one `User`, and has many `Comment`s +- A `Subforum` can have a parent `Subforum` (that's how "Forum → Announcements" nesting works) + +`models.py` also contains helper functions: +- `valid_title()` / `valid_content()` — enforce length rules before saving +- `generateLinkPath()` — builds the breadcrumb trail (e.g. Forum Index / Forum / Announcements) +- `error()` — returns a red HTML error string for simple inline errors + +--- + +## Layer 3 — Route Handlers: `routes.py` + `user.py` + +This is where a browser request meets your code. Every route follows the same pattern: + +``` +@rt.route('/path', methods=['GET'|'POST']) +def handler(): + 1. Read input (request.args for URL params, request.form for POST data) + 2. Query DB (Model.query.filter(...).first() or .all()) + 3. Do logic (validate, create objects, append relationships) + 4. Write DB (db.session.add(), db.session.commit()) + 5. Return (render_template(...) or redirect(...)) +``` + +### Route Map + +| Route | Method | What it does | +|---|---|---| +| `/` | GET | List all top-level subforums → `subforums.html` | +| `/subforum?sub=` | GET | Show one subforum + its posts → `subforum.html` | +| `/loginform` | GET | Show login/register form → `login.html` | +| `/action_login` | POST | Check username+password → redirect `/` or show errors | +| `/action_logout` | GET | Clear session → redirect `/` | +| `/action_createaccount` | POST | Validate + create `User` → redirect `/` | +| `/addpost?sub=` | GET | Show post creation form → `createpost.html` | +| `/action_post?sub=` | POST | Validate + save new `Post` → redirect `/viewpost` | +| `/viewpost?post=` | GET | Show one post + its comments → `viewpost.html` | +| `/action_comment?post=` | POST | Save new `Comment` → redirect `/viewpost` | + +`user.py` is a helper module — `username_taken()`, `email_taken()`, `valid_username()` — called by `action_createaccount`. + +--- + +## Layer 4 — Templates (the Front End)P + +Templates are HTML files with **Jinja2** — Flask's templating language. +- `{{ variable }}` — outputs a value into the HTML +- `{% for x in list %}` / `{% if condition %}` — runs logic + +### Template Hierarchy + +``` +layout.html ← base shell: loads CSS, renders header, shows errors block + └─ header.html ← nav bar (included into layout on every page) + └─ subforums.html ← extends layout → loops over subforums, renders links + └─ subforum.html ← extends layout → shows one subforum + post list + └─ login.html ← extends layout → login form + register form + └─ createpost.html ← extends layout → title + content textarea form + └─ viewpost.html ← extends layout → post body + comment list + comment form +``` + +**No JavaScript logic** — all interactivity is server-side. The one JS snippet in +`viewpost.html` is just a `toggle()` call to show/hide the comment box. + +--- + +## A Complete Example: Posting a Comment + +``` +1. Browser visits /viewpost?post=3 + → Flask calls viewpost() in routes.py + → Queries Post WHERE id=3 + → Queries Comments WHERE post_id=3 + → render_template("viewpost.html", post=..., comments=...) + → Jinja2 fills in the HTML + → Browser receives the finished page + +2. User types a comment, clicks "Comment" + + # CircusCircus PostgreSQL migration and Docker workflow + + ## Step-by-step workflow + + 1. **Remove Heroku**: All Heroku instructions and configs are deprecated. See Heroku.md for new Docker/PostgreSQL steps. + 2. **Install dependencies**: + - Local: `pip install psycopg2-binary` + - Container: `psycopg2` in requirements.txt + 3. **.env setup**: + - Add `DATABASE_URL=postgresql://ccuser:@db/circuscircus` (replace ``) + 4. **config.py**: + - Loads DB URI from .env using python-dotenv + - Host is `db` (Docker service name) + 5. **docker-compose.yml**: + - Defines `db` (Postgres) and `web` (app) services + - Links services, sets env vars, exposes ports + 6. **Testing**: + - `docker compose up` + - `docker exec -it bash` + - Run `python` shell: + ```python + from forum.models import db + db.create_all() + # Seed subforums, test CRUD for User, Post, Comment, Subforum + ``` + 7. **Deploy**: + - `docker build -t circuscircus .` + - `docker push /circuscircus` + - Deploy to Railway, Render, Fly.io, etc. + - Smoke test live URL + + --- + + # [WHY] This workflow ensures local and production environments match, with secure credential handling and persistent data. + # [EFFECT] Simplifies onboarding, testing, and deployment for all contributors. diff --git a/references/config_annotated_cp.py b/references/config_annotated_cp.py new file mode 100644 index 0000000..dcb174f --- /dev/null +++ b/references/config_annotated_cp.py @@ -0,0 +1,21 @@ +""" +[WHY] Flask configuration for CircusCircus. Loads DB URI from .env for Docker/PostgreSQL support. +[SECURITY] Never hardcode secrets in source files; use .env for credentials. +[IMPORT] dotenv for .env loading, os for env vars. +""" +from os import environ, path +from dotenv import load_dotenv + +basedir = path.abspath(path.dirname(__file__)) +load_dotenv(path.join(basedir, '.env')) + +class Config: + """[CLASS] Flask config object. Loads DB URI from env, falls back to SQLite for dev.""" + # [FIELD] General Config + SECRET_KEY = 'kristofer' # [SECURITY] Replace in production + FLASK_APP = 'forum.app' + + # [FIELD] Database + SQLALCHEMY_DATABASE_URI = environ.get('DATABASE_URL', 'sqlite:///circuscircus.db') + SQLALCHEMY_ECHO = False + SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/references/docker-compose.yml_annotated_cp.yaml b/references/docker-compose.yml_annotated_cp.yaml new file mode 100644 index 0000000..b8fa561 --- /dev/null +++ b/references/docker-compose.yml_annotated_cp.yaml @@ -0,0 +1,27 @@ +# Annotated reference for docker-compose.yml +# [WHY] This file defines the multi-container setup for local and production deployment using Docker Compose. +# [EFFECT] Ensures the app and PostgreSQL DB are started together, with correct networking and environment variables. + +version: '3.8' # [CONSTANT] Compose file version +services: + db: # [SERVICE] PostgreSQL database service + image: postgres # [CONSTANT] Official postgres image + environment: + POSTGRES_USER: ccuser # [CONSTANT] DB username + POSTGRES_PASSWORD: # [SECURITY] Replace with a secure password + POSTGRES_DB: circuscircus # [CONSTANT] DB name + ports: + - "5432:5432" # [EFFECT] Exposes DB port for local dev + volumes: + - circuscircus-db-data:/var/lib/postgresql/data # [EFFECT] Persists DB data + web: # [SERVICE] Flask/Gunicorn app + build: . # [EFFECT] Builds from Dockerfile in project root + command: gunicorn forum.app:app # [EFFECT] Runs app with Gunicorn + environment: + DATABASE_URL: postgresql://ccuser:@db/circuscircus # [EFFECT] App DB connection string + depends_on: + - db # [EFFECT] Ensures DB starts before app + ports: + - "8000:8000" # [EFFECT] Exposes app port +volumes: + circuscircus-db-data: # [EFFECT] Named volume for DB persistence diff --git a/references/dotenv_annotated_cp.env b/references/dotenv_annotated_cp.env new file mode 100644 index 0000000..2809435 --- /dev/null +++ b/references/dotenv_annotated_cp.env @@ -0,0 +1,4 @@ +# CircusCircus .env reference copy +# [WHY] Centralizes DB credentials for local and container use +# [SECURITY] Never commit real passwords to version control +DATABASE_URL=postgresql://ccuser:@db/circuscircus diff --git a/requirements.txt b/requirements.txt index 39287bc..574fb45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,17 @@ -Flask -Flask-Login -Flask-SQLAlchemy -gunicorn -itsdangerous -Jinja2 -MarkupSafe -SQLAlchemy -Werkzeug -wheel -honcho \ No newline at end of file +blinker==1.9.0 +click==8.4.0 +Flask==3.1.3 +Flask-Login==0.6.3 +Flask-SQLAlchemy==3.1.1 +gunicorn==26.0.0 +honcho==2.0.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +packaging==26.2 +psycopg2-binary==2.9.12 +python-dotenv==1.2.2 +SQLAlchemy==2.0.49 +typing_extensions==4.15.0 +Werkzeug==3.1.8 +wheel==0.47.0 From c0d93e6b34a6bb1c6d321e568e3d4275d683620c Mon Sep 17 00:00:00 2001 From: niciahrymer-hillian Date: Thu, 21 May 2026 13:27:55 -0400 Subject: [PATCH 08/10] updated reference docs --- references/Dockerfile_annotated_cp.py | 2 +- references/WALKTHROUGH.md | 9 ++++-- references/config_annotated_cp.py | 6 +++- .../docker-compose.yml_annotated_cp.yaml | 28 +++++++++++++++---- references/dotenv_annotated_cp.env | 5 ++-- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/references/Dockerfile_annotated_cp.py b/references/Dockerfile_annotated_cp.py index 571b0ee..7b0b4fb 100644 --- a/references/Dockerfile_annotated_cp.py +++ b/references/Dockerfile_annotated_cp.py @@ -28,4 +28,4 @@ # [CMD] Run gunicorn CMD ["gunicorn", "forum.app:app", "--bind", "0.0.0.0:8000"] -# [WHY] Launches the Flask app using gunicorn for production +# [WHY] Launches the Flask app using gunicorn for production, binds to all interfaces for Docker diff --git a/references/WALKTHROUGH.md b/references/WALKTHROUGH.md index a9afe3e..18a4bce 100644 --- a/references/WALKTHROUGH.md +++ b/references/WALKTHROUGH.md @@ -166,17 +166,20 @@ layout.html ← base shell: loads CSS, renders header, shows errors blo ## Step-by-step workflow - 1. **Remove Heroku**: All Heroku instructions and configs are deprecated. See Heroku.md for new Docker/PostgreSQL steps. + 1. **Switch to Docker**: All Heroku instructions and configs are deprecated. Use Docker Compose for local and production workflows. 2. **Install dependencies**: - Local: `pip install psycopg2-binary` - - Container: `psycopg2` in requirements.txt + - Container: `psycopg2-binary` in requirements.txt 3. **.env setup**: - - Add `DATABASE_URL=postgresql://ccuser:@db/circuscircus` (replace ``) + - Add `DATABASE_URL=postgresql://ccuser:Elephant@db/circuscircus` (replace `Elephant` with your password) 4. **config.py**: - Loads DB URI from .env using python-dotenv - Host is `db` (Docker service name) + - Fallbacks to SQLite for local dev if env var is missing 5. **docker-compose.yml**: - Defines `db` (Postgres) and `web` (app) services + - Healthcheck ensures app waits for DB readiness + - Uses custom Docker network for isolation - Links services, sets env vars, exposes ports 6. **Testing**: - `docker compose up` diff --git a/references/config_annotated_cp.py b/references/config_annotated_cp.py index dcb174f..873238c 100644 --- a/references/config_annotated_cp.py +++ b/references/config_annotated_cp.py @@ -10,12 +10,16 @@ load_dotenv(path.join(basedir, '.env')) class Config: - """[CLASS] Flask config object. Loads DB URI from env, falls back to SQLite for dev.""" + """[CLASS] Flask config object. Loads DB URI from env, falls back to SQLite for dev. + [WHY] Supports both Docker/PostgreSQL and local SQLite by reading DATABASE_URL from .env or environment. + """ # [FIELD] General Config SECRET_KEY = 'kristofer' # [SECURITY] Replace in production FLASK_APP = 'forum.app' # [FIELD] Database + # [WHY] Use DATABASE_URL from .env for Docker/PostgreSQL, fallback to SQLite for local dev + # [EFFECT] Host must be 'db' (Docker service name) for container linking SQLALCHEMY_DATABASE_URI = environ.get('DATABASE_URL', 'sqlite:///circuscircus.db') SQLALCHEMY_ECHO = False SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/references/docker-compose.yml_annotated_cp.yaml b/references/docker-compose.yml_annotated_cp.yaml index b8fa561..b35707a 100644 --- a/references/docker-compose.yml_annotated_cp.yaml +++ b/references/docker-compose.yml_annotated_cp.yaml @@ -2,26 +2,42 @@ # [WHY] This file defines the multi-container setup for local and production deployment using Docker Compose. # [EFFECT] Ensures the app and PostgreSQL DB are started together, with correct networking and environment variables. -version: '3.8' # [CONSTANT] Compose file version services: db: # [SERVICE] PostgreSQL database service image: postgres # [CONSTANT] Official postgres image environment: POSTGRES_USER: ccuser # [CONSTANT] DB username - POSTGRES_PASSWORD: # [SECURITY] Replace with a secure password + POSTGRES_PASSWORD: Elephant # [SECURITY] Example password, replace in production POSTGRES_DB: circuscircus # [CONSTANT] DB name ports: - "5432:5432" # [EFFECT] Exposes DB port for local dev volumes: - - circuscircus-db-data:/var/lib/postgresql/data # [EFFECT] Persists DB data + - circuscircus-db-data:/var/lib/postgresql # [EFFECT] Persists DB data + networks: + - app-network # [EFFECT] Isolates DB and app in a private network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ccuser -d circuscircus"] # [EFFECT] Wait for DB to be ready + interval: 5s + timeout: 5s + retries: 5 + web: # [SERVICE] Flask/Gunicorn app build: . # [EFFECT] Builds from Dockerfile in project root - command: gunicorn forum.app:app # [EFFECT] Runs app with Gunicorn + command: gunicorn forum.app:app --bind 0.0.0.0:8000 # [EFFECT] Runs app with Gunicorn, binds all interfaces + env_file: + - .env # [EFFECT] Loads environment variables from .env file environment: - DATABASE_URL: postgresql://ccuser:@db/circuscircus # [EFFECT] App DB connection string + DATABASE_URL: postgresql://ccuser:Elephant@db/circuscircus # [EFFECT] App DB connection string depends_on: - - db # [EFFECT] Ensures DB starts before app + db: + condition: service_healthy # [EFFECT] Wait for DB healthcheck ports: - "8000:8000" # [EFFECT] Exposes app port + networks: + - app-network # [EFFECT] Isolates app and DB + +networks: + app-network: # [EFFECT] Custom Docker network for isolation + volumes: circuscircus-db-data: # [EFFECT] Named volume for DB persistence diff --git a/references/dotenv_annotated_cp.env b/references/dotenv_annotated_cp.env index 2809435..61b6f96 100644 --- a/references/dotenv_annotated_cp.env +++ b/references/dotenv_annotated_cp.env @@ -1,4 +1,5 @@ # CircusCircus .env reference copy -# [WHY] Centralizes DB credentials for local and container use +# [WHY] Centralizes DB credentials for local and Docker/PostgreSQL use # [SECURITY] Never commit real passwords to version control -DATABASE_URL=postgresql://ccuser:@db/circuscircus +# [EFFECT] Used by both Flask app and Docker Compose for DB connection +DATABASE_URL=postgresql://ccuser:Elephant@db/circuscircus From 38b0681e0c55423bc6a893cf3ec8316870e4238d Mon Sep 17 00:00:00 2001 From: ShockaHolmes Date: Thu, 21 May 2026 14:00:28 -0400 Subject: [PATCH 09/10] Created public and private post an dMarkdown test --- forum/__init__.py | 12 ++ forum/models.py | 1 + forum/routes.py | 8 +- forum/static/style.css | 15 +++ forum/templates/createpost.html | 10 ++ forum/templates/subforum.html | 3 + forum/templates/viewpost.html | 5 +- requirements.txt | 4 +- tests/test_visibility_markdown.py | 190 ++++++++++++++++++++++++++++++ 9 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 tests/test_visibility_markdown.py diff --git a/forum/__init__.py b/forum/__init__.py index c10b0f3..4b0b1ec 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,10 +1,22 @@ from flask import Flask +from markupsafe import Markup +import mistune from forum.routes import rt def create_app(): """Construct the core application.""" app = Flask(__name__, instance_relative_config=False) app.config.from_object('config.Config') + + markdown_renderer = mistune.create_markdown(escape=True) + + def render_markdown(text): + if not text: + return "" + return Markup(markdown_renderer(text)) + + app.jinja_env.filters['markdown'] = render_markdown + # I think more blueprints might be used to break routes up into things like # post_routes # subforum_routes diff --git a/forum/models.py b/forum/models.py index 7fedd2f..c953418 100644 --- a/forum/models.py +++ b/forum/models.py @@ -30,6 +30,7 @@ class Post(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.Text) content = db.Column(db.Text) + is_public = db.Column(db.Boolean, default=True, nullable=False) comments = db.relationship("Comment", backref="post") reactions = db.relationship("Reaction", backref="post") user_id = db.Column(db.Integer, db.ForeignKey('user.id')) diff --git a/forum/routes.py b/forum/routes.py index 61cb6f8..22e61b7 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -105,7 +105,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).all() + post_query = Post.query.filter(Post.subforum_id == subforum_id) + if not current_user.is_authenticated: + post_query = post_query.filter(Post.is_public == True) + posts = post_query.order_by(Post.id.desc()).limit(50).all() subforumpath = subforum.path if not subforumpath: subforumpath = generateLinkPath(subforum.id) @@ -135,7 +138,7 @@ def addpost(): def viewpost(): postid = int(request.args.get("post")) post = Post.query.filter(Post.id == postid).first() - if not post: + if not post or (not post.is_public and not current_user.is_authenticated): return error("That post does not exist!") comment_error = request.args.get("comment_error") subforumpath = post.subforum.path @@ -218,6 +221,7 @@ def action_post(): if retry: return render_template("createpost.html",subforum=subforum, errors=errors) post = Post(title, content, datetime.datetime.now()) + post.is_public = request.form.get("is_public") == "on" subforum.posts.append(post) user.posts.append(post) db.session.commit() diff --git a/forum/static/style.css b/forum/static/style.css index c5483af..e47488d 100644 --- a/forum/static/style.css +++ b/forum/static/style.css @@ -234,3 +234,18 @@ font-style: italic; color: #444; } + +.visibilitybadge{ + display: inline-block; + margin-left: 0.5em; + padding: 0.05em 0.55em; + font-size: 9pt; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.04em; + border: 1px solid #666; + border-radius: 999px; + background: #f1f1f1; + color: #333; + vertical-align: middle; +} diff --git a/forum/templates/createpost.html b/forum/templates/createpost.html index 85947a6..2c001db 100644 --- a/forum/templates/createpost.html +++ b/forum/templates/createpost.html @@ -13,7 +13,17 @@
+
+ +
+
+
+ Markdown is supported. Try bold with **text**, *italics*, `code`, and links like [title](https://example.com). +
diff --git a/forum/templates/subforum.html b/forum/templates/subforum.html index 17f6c81..f21e873 100644 --- a/forum/templates/subforum.html +++ b/forum/templates/subforum.html @@ -45,6 +45,9 @@
{{ post.title }} + {% if current_user.is_authenticated and not post.is_public %} + Private + {% endif %}
by {{post.user.username}} diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 6291eaa..4aba305 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -5,6 +5,9 @@
{{post.title}} + {% if current_user.is_authenticated and not post.is_public %} + Private + {% endif %}
{{ post.user.username }} @@ -14,7 +17,7 @@
- {{post.content}} + {{ post.content | markdown }}
{% if current_user.is_authenticated %} diff --git a/requirements.txt b/requirements.txt index 39287bc..1ec07b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Flask Flask-Login Flask-SQLAlchemy +mistune gunicorn itsdangerous Jinja2 @@ -8,4 +9,5 @@ MarkupSafe SQLAlchemy Werkzeug wheel -honcho \ No newline at end of file +honcho +pytest \ No newline at end of file diff --git a/tests/test_visibility_markdown.py b/tests/test_visibility_markdown.py new file mode 100644 index 0000000..8007020 --- /dev/null +++ b/tests/test_visibility_markdown.py @@ -0,0 +1,190 @@ +import datetime + +import pytest +from flask_login import LoginManager + +import config +from forum import create_app +from forum.models import Comment, Post, Subforum, User, db + + +@pytest.fixture() +def app(tmp_path): + db_file = tmp_path / "test.db" + original_uri = config.Config.SQLALCHEMY_DATABASE_URI + config.Config.SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_file}" + + app = create_app() + app.config.update(TESTING=True) + + login_manager = LoginManager() + login_manager.init_app(app) + + @login_manager.user_loader + def load_user(user_id): + return db.session.get(User, int(user_id)) + + with app.app_context(): + db.drop_all() + db.create_all() + + yield app + + with app.app_context(): + db.session.remove() + db.drop_all() + + config.Config.SQLALCHEMY_DATABASE_URI = original_uri + + +@pytest.fixture() +def client(app): + return app.test_client() + + +def _seed_user(username="alice", email="alice@example.com"): + user = User(email, username, "password123") + db.session.add(user) + db.session.commit() + return user.id + + +def _seed_subforum(title="General", description="General discussion"): + subforum = Subforum(title, description) + db.session.add(subforum) + db.session.commit() + return subforum.id + + +def _seed_post(subforum, user, title, content, is_public): + post = Post(title, content, datetime.datetime.now()) + post.is_public = is_public + post.subforum_id = subforum + post.user_id = user + db.session.add(post) + db.session.commit() + return post.id + + +def _log_in(client, user): + with client.session_transaction() as session: + session["_user_id"] = str(user) + session["_fresh"] = True + + +def test_subforum_hides_private_posts_for_anonymous_users(client, app): + with app.app_context(): + user = _seed_user() + subforum = _seed_subforum() + _seed_post(subforum, user, "Public Post", "Visible to everyone", True) + _seed_post(subforum, user, "Private Post", "Visible to logged in only", False) + + response = client.get(f"/subforum?sub={subforum}") + body = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Public Post" in body + assert "Private Post" not in body + assert "visibilitybadge" not in body + + +def test_subforum_shows_private_badge_for_authenticated_users(client, app): + with app.app_context(): + user = _seed_user() + subforum = _seed_subforum() + _seed_post(subforum, user, "Members Only", "Private content", False) + + _log_in(client, user) + response = client.get(f"/subforum?sub={subforum}") + body = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Members Only" in body + assert "Private" in body + assert "visibilitybadge" in body + + +def test_viewpost_blocks_private_post_for_anonymous_users(client, app): + with app.app_context(): + user = _seed_user() + subforum = _seed_subforum() + post = _seed_post(subforum, user, "Secret", "Hidden", False) + + response = client.get(f"/viewpost?post={post}") + body = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "That post does not exist!" in body + + +def test_viewpost_renders_markdown_and_escapes_html(client, app): + with app.app_context(): + user = _seed_user() + subforum = _seed_subforum() + content = "**bold** and *italic* " + post = _seed_post(subforum, user, "Markdown", content, True) + + response = client.get(f"/viewpost?post={post}") + body = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "bold" in body + assert "italic" in body + assert "<script>alert('x')</script>" in body + + +def test_viewpost_shows_private_badge_for_authenticated_users(client, app): + with app.app_context(): + user = _seed_user() + subforum = _seed_subforum() + post = _seed_post(subforum, user, "Staff Note", "Private body", False) + comment = Comment("hello", datetime.datetime.now()) + comment.user_id = user + comment.post_id = post + db.session.add(comment) + db.session.commit() + + _log_in(client, user) + response = client.get(f"/viewpost?post={post}") + body = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Staff Note" in body + assert "Private" in body + assert "visibilitybadge" in body + + +def test_action_post_sets_visibility_from_checkbox(client, app): + with app.app_context(): + user = _seed_user("poster", "poster@example.com") + subforum = _seed_subforum("Posting", "Posting tests") + + _log_in(client, user) + + response_private = client.post( + f"/action_post?sub={subforum}", + data={ + "title": "Private From Form", + "content": "This content is long enough to be accepted.", + }, + ) + assert response_private.status_code == 302 + + response_public = client.post( + f"/action_post?sub={subforum}", + data={ + "title": "Public From Form", + "content": "This content is also long enough to be accepted.", + "is_public": "on", + }, + ) + assert response_public.status_code == 302 + + with app.app_context(): + private_post = Post.query.filter(Post.title == "Private From Form").first() + public_post = Post.query.filter(Post.title == "Public From Form").first() + + assert private_post is not None + assert public_post is not None + assert private_post.is_public is False + assert public_post.is_public is True From 5ecf7ee47aa22c798ae9be94d750d6be176e73c5 Mon Sep 17 00:00:00 2001 From: niciahrymer-hillian Date: Thu, 21 May 2026 14:20:16 -0400 Subject: [PATCH 10/10] commit before merge --- forum/templates/layout.html | 8 ++- forum/templates/subforums.html | 34 ++++++----- forum/templates/viewpost.html | 101 ++++++++++++++++----------------- 3 files changed, 76 insertions(+), 67 deletions(-) diff --git a/forum/templates/layout.html b/forum/templates/layout.html index a2a5e14..820e9c3 100644 --- a/forum/templates/layout.html +++ b/forum/templates/layout.html @@ -21,7 +21,13 @@
{% endif %}
- {% block body %}{% endblock %} +
+
+
+ {% block body %}{% endblock %} +
+
+
diff --git a/forum/templates/subforums.html b/forum/templates/subforums.html index 8cee77d..930c61e 100644 --- a/forum/templates/subforums.html +++ b/forum/templates/subforums.html @@ -1,18 +1,24 @@ {% extends 'layout.html' %} {% block body%} -
- Top level subforums: +
+
+
+
+ Top level subforums: +
+ {% for subforum in subforums %} +
+ +
+ {{ subforum.description }} +
+
+ {% endfor %} +
+
-{% for subforum in subforums %} -
- -
-{{ subforum.description }} -
-
-{% endfor %} {% endblock %} diff --git a/forum/templates/viewpost.html b/forum/templates/viewpost.html index 3f489ca..c010b55 100644 --- a/forum/templates/viewpost.html +++ b/forum/templates/viewpost.html @@ -1,61 +1,58 @@ {% extends 'layout.html' %} {% block body %} -{{ path|safe}} +
+
+
+ {{ path|safe }} -
-
- {{post.title}} -
- {{ post.user.username }} - -
-
- {{ post.get_time_string() }} +
+
+ {{post.title}} +
+ {{ post.user.username }} +
+
+ {{ post.get_time_string() }} +
+
+
+ {{post.content}} +
+
+
+
+
+ +
+
+
+ {% if current_user.is_authenticated %} + + {% else %} + Login or register to make a comment + {% endif %} +
+ {% if comments %} +
+ {% for comment in comments %} +
+
+ ({{ comment.user.username }}) - +
+
+ {{ comment.content }} +
+
+ {{ comment.get_time_string() }} +
+
+
+ {% endfor %} +
+ {% endif %}
-
- {{post.content}} -
- -
-
-
-
- -
-
-
- - - - {% if current_user.is_authenticated %} - - - {% else %} - Login or register to make a comment - {% endif %} -
-{%if comments%} -
-{% for comment in comments %} - -
-
- ({{ comment.user.username }}) - -
-
- {{ comment.content }} -
- -
- {{ comment.get_time_string() }} -
-
-
- -{% endfor %}
-{% endif %}