diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..349fad4 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 3729cd4..65fdf95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ venv/ .vscode +# Uploaded user files โ€” kept locally only, not committed +forum/static/uploads/* +!forum/static/uploads/.gitkeep + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/CIRCUSCIRCUS_FULL_CODE_GUIDE.md b/CIRCUSCIRCUS_FULL_CODE_GUIDE.md new file mode 100644 index 0000000..69a77b1 --- /dev/null +++ b/CIRCUSCIRCUS_FULL_CODE_GUIDE.md @@ -0,0 +1,890 @@ +# CircusCircus Beginner-to-Advanced Code Guide + +This guide explains the whole project in plain language first, then technical detail second. + +Goal of this guide: + +- Explain what the project does. +- Explain what every important file does. +- Explain what each function/method does and why. +- Explain the Python operators and patterns used in this codebase. +- Explain classes, objects, and instance variables in this project. +- Explain why MySQL is configured this way. + +If you are new, start at Section 1 and go in order. + +## 1) One-Sentence Project Summary + +CircusCircus is a Flask forum app where users can register, log in, browse subforums, create posts, and add comments. + +## 2) What Happens When You Run It + +You run: + +```bash +bash ./run.sh +``` + +Then the app does this: + +1. Creates a virtual environment if needed. +2. Installs Python packages from requirements.txt. +3. Sets default environment variables for MySQL and Flask. +4. Starts Flask using forum.app. +5. Loads config.py (which checks MySQL setup). +6. Builds app + routes. +7. Creates database tables if missing. +8. Seeds default subforums if database is empty. +9. Serves pages in browser. + +## 3) Why MySQL Is Configured This Way + +This is very important for your team. + +### Problem being solved + +A MySQL app needs more setup than SQLite: + +- server must be running +- database must exist +- user account must exist +- user must have permission to that database +- app must have correct connection string + +If any one of these is wrong, app startup fails. + +### Why config.py does setup checks + +config.py tries to reduce first-run setup pain by: + +- attempting a local admin connection +- creating database/user if possible +- checking app login credentials +- printing clear messages when setup cannot be done automatically + +This means teammates do not need to memorize SQL commands just to run locally. + +### Why defaults are what they are + +The defaults in this project are: + +- DB_USER = zipchat_app +- DB_PASSWORD = password +- DB_HOST = 127.0.0.1 +- DB_PORT = 3306 +- DB_NAME = ZipChat + +Reason: + +- predictable local setup +- fewer required manual exports +- consistent behavior across machines + +### Why root/admin is not used for normal app queries + +The app should not run day-to-day as root because: + +- root can alter everything in the server +- app bugs become more dangerous +- principle of least privilege says give only needed rights + +So setup uses root/admin only for creation/grants, then app runs as zipchat_app. + +### Why cryptography dependency exists + +Modern MySQL often uses caching_sha2_password. +PyMySQL needs cryptography installed to handle that authentication flow. +Without it, login can fail even if username/password are correct. + +### Why some model fields changed from Text to String + +MySQL does not allow UNIQUE indexes on plain TEXT columns without key length. +The app needed unique username/email/title behavior, so those became bounded String fields. +This keeps behavior correct and MySQL-compatible. + +## 4) Python Basics Used in This Project + +This section explains symbols/operators you see in the code. + +### Assignment and values + +- = + - Assigns a value. + - Example idea: x = 5 means store 5 in x. + +- return + - Sends a value back from a function. + +### Comparison operators + +- == : equal to +- != : not equal to +- > : greater than +- < : less than +- >= : greater than or equal +- <= : less than or equal + +Used in validation and query checks. + +### Boolean logic + +- and : both conditions true +- or : either condition true +- not : invert true/false + +Used heavily in form validation and condition branching. + +### Identity / null checks + +- is None + - checks whether a variable is the special None value +- is not None + - opposite of above + +Used when checking if queries found a record. + +### Membership and iteration + +- in + - checks membership or iterates over lists + +Used in loops through posts/subforums/comments. + +### Control flow keywords + +- if / elif / else + - choose one branch based on condition +- for + - loop over collection +- while + - loop while condition holds (used less here) +- try / except + - catch and handle runtime errors cleanly + +Used in MySQL setup where connections can fail for many reasons. + +### Function and class syntax + +- def function_name(...): + - define a function +- class ClassName(...): + - define a class + +### Decorators you see in Flask code + +- @rt.route(...) + - registers URL route for a function +- @login_required + - blocks route unless user is authenticated +- @login_manager.user_loader + - tells Flask-Login how to reload user from session id + +## 5) Classes, Objects, and Instance Variables (In This App) + +### Class + +A class is a blueprint. +Example: User class defines what a user record looks like. + +### Object (instance) + +An object is one real thing created from a class. +Example: one registered person is one User object. + +### Instance variable + +An instance variable is data stored on one object. +Examples in User: + +- self.username +- self.email +- self.password_hash + +Each user object has its own values. + +### Class variable (important note) + +If a variable is defined directly inside class body (not in __init__), it can act like shared class-level data. +In Post and Comment, lastcheck/savedresponce are declared at class level, so understand they are not typical per-row database columns. + +## 6) Full File-by-File Explanation + +## 6.1 run.sh + +Purpose: + +- one command local startup script + +What each step does: + +1. set -e + +- stop script if a command fails + +1. find script folder and cd into it + +- ensures consistent working directory + +1. create .venv if missing + +- isolates Python dependencies for this project + +1. activate .venv and install requirements + +- guarantees modules like Flask/PyMySQL exist + +1. export defaults for Flask + MySQL + +- avoids requiring manual environment setup on every run + +1. run flask + +- starts app server + +Why this file exists: + +- standardizes startup for every teammate + +## 6.2 config.py + +Purpose: + +- app configuration + MySQL setup checks + +Functions: + +1. try_admin_connection() + +- tries local MySQL admin access in multiple ways +- returns connection + method string on success +- returns None tuple on failure +- Why: local MySQL access varies by machine + +1. setup_database_and_user() + +- creates database ZipChat if missing +- creates zipchat_app if missing +- grants privileges +- Why: first run should be easy + +1. check_mysql_connection() + +- tests app-level login with DB_* settings +- translates common MySQL error codes into readable messages +- Why: actionable startup feedback + +1. check_mysql_and_setup() + +- runs setup then verification in one place +- Why: predictable boot order + +Class Config: + +- holds Flask settings and SQLAlchemy database URI +- Why: single source of truth for app config + +Key instance-like settings in Config: + +- SECRET_KEY +- FLASK_APP +- DB_USER +- DB_PASSWORD +- DB_HOST +- DB_PORT +- DB_NAME +- SQLALCHEMY_DATABASE_URI +- SQLALCHEMY_ECHO +- SQLALCHEMY_TRACK_MODIFICATIONS + +## 6.3 forum/__init__.py + +Purpose: + +- app factory module + +Function: create_app() + +- builds Flask app object +- loads Config from config.py +- registers routes blueprint +- binds SQLAlchemy db object +- Why: clean startup composition and easier maintenance + +## 6.4 forum/app.py + +Purpose: + +- runtime entry module + seed logic + home route + +Function: init_site() + +- creates default forum categories on first run +- Why: user sees usable forum immediately + +Function: add_subforum(title, description, parent=None) + +- creates one subforum if not duplicate in same level +- attaches to parent if provided +- commits change +- Why: reusable seeding helper + +Function: load_user(userid) + +- fetches user by ID for Flask-Login +- Why: required session restoration hook + +Route: index() + +- URL / +- reads top-level subforums +- renders subforums.html +- Why: landing page of app + +Startup app_context block: + +- runs db.create_all() +- seeds subforums if none exist +- Why: ensure data structures are ready + +## 6.5 forum/models.py + +Purpose: + +- data schema + model behavior helpers + +Global: db = SQLAlchemy() + +- shared ORM object used by app factory and models + +Class User(UserMixin, db.Model) +Instance variables/columns: + +- id: primary key integer +- username: unique login/display name +- password_hash: secure hashed password +- email: unique email +- admin: boolean role flag +Relationships: +- posts: user has many posts +- comments: user has many comments +Methods: +- __init__(email, username, password): stores hashed password +- check_password(password): verifies password attempt +Why class exists: +- stores identity + authentication + +Class Post(db.Model) +Columns: + +- id, title, content, user_id, subforum_id, postdate +Relationship: +- comments +Methods: +- __init__(title, content, postdate) +- get_time_string(): human-friendly time label +Why class exists: +- stores forum threads + +Class Subforum(db.Model) +Columns: + +- id, title, description, parent_id, hidden +Relationships: +- subforums (children) +- posts +Method: +- __init__(title, description) +Why class exists: +- stores category tree + +Class Comment(db.Model) +Columns: + +- id, content, postdate, user_id, post_id +Methods: +- __init__(content, postdate) +- get_time_string() +Why class exists: +- stores responses under posts + +Helper function: error(errormessage) + +- returns simple red HTML message string +- Why: quick inline error output + +Helper function: generateLinkPath(subforumid) + +- builds breadcrumb links up parent chain +- Why: consistent navigation path text + +Helper function: valid_title(title) + +- checks title length limits +- Why: basic quality control + +Helper function: valid_content(content) + +- checks post body length limits +- Why: basic quality control + +## 6.6 forum/user.py + +Purpose: + +- lightweight account validation helpers + +Variables: + +- password_regex: allowed password format +- username_regex: allowed username format + +Functions: + +1. valid_username(username) + +- regex check for username +- Why: prevent invalid names + +1. valid_password(password) + +- regex check for password format +- Why: optional password policy helper + +1. username_taken(username) + +- query existing user +- Why: avoid duplicates + +1. email_taken(email) + +- query existing email +- Why: avoid duplicate accounts + +## 6.7 forum/routes.py + +Purpose: + +- all route handlers for auth + browsing + posting + comments + +Blueprint variable: + +- rt +- Why: register group of routes into app factory + +Routes and why each exists: + +1. action_login() -> /action_login (POST) + +- verifies credentials +- starts user session +- Why: login workflow + +1. action_logout() -> /action_logout (GET, login_required) + +- ends session +- Why: logout workflow + +1. action_createaccount() -> /action_createaccount (POST) + +- validates inputs +- creates user +- logs in user +- Why: signup workflow + +1. subforum() -> /subforum (GET) + +- loads one category and its posts/subcategories +- Why: category browsing + +1. loginform() -> /loginform (GET) + +- renders login/register page +- Why: UI entry for auth + +1. addpost() -> /addpost (GET, login_required) + +- shows create post form +- Why: start post creation + +1. viewpost() -> /viewpost (GET) + +- shows post + comments +- Why: post detail page + +1. comment() -> /action_comment (POST/GET, login_required) + +- creates and saves comment +- Why: user interaction under posts + +1. action_post() -> /action_post (POST, login_required) + +- validates + saves new post +- Why: create content + +## 6.8 Templates and Static Files + +Templates folder explains UI pages: + +- layout.html: main page shell +- header.html: top bar with auth status +- subforums.html: top categories page +- subforum.html: category detail page +- viewpost.html: post + comments page +- createpost.html: post form page +- login.html: login + signup forms + +Static files: + +- bootstrap.min.css: framework styles +- style.css: project custom styles + +## 7) How to Read and Understand the Script (Recommended Team Workflow) + +Use this order every time: + +1. Read run.sh + +- understand startup environment and defaults + +1. Read config.py + +- understand MySQL behavior and URI construction + +1. Read forum/__init__.py + +- understand how app is assembled + +1. Read forum/app.py + +- understand startup table creation and seed logic + +1. Read forum/models.py + +- understand data structure and relationships + +1. Read forum/routes.py + +- understand user behavior endpoint by endpoint + +1. Read templates alongside each route + +- understand how backend data appears to user + +Debugging method: + +- Start from URL route function. +- Identify model queries/writes. +- Identify template rendered. +- Confirm commit/redirect path. + +## 8) Common Confusions (Beginner Notes) + +1. Why do we call db.create_all() on startup? + +- To create missing tables automatically in dev/local runs. + +1. Why can setup warning appear but app still work? + +- Admin setup can fail while app user login still succeeds. + +1. Why not hardcode root in app connection? + +- Root is too powerful for normal app runtime. + +1. Why environment variables and defaults both? + +- Defaults make local run easy. +- Environment variables allow overrides for different machines. + +## 9) Short Team Script (Say This in Standup) + +"CircusCircus is a Flask + MySQL forum app. run.sh starts local environment and flask. config.py checks MySQL setup and builds DB connection. models.py defines users/posts/subforums/comments. routes.py handles login, signup, browsing, posting, and comments. app.py creates tables and seeds default subforums. Templates render each page." + +That gives everyone the same mental model quickly. + +## 10) In-Depth Appendix: How and Why the System Works + +This section is intentionally deeper than the rest of the guide. It is for anyone who wants to understand design choices, side effects, and implementation mechanics beyond the beginner view. + +### 10.1 Application Lifecycle (Import Time vs Runtime) + +The app has two important phases: + +1. Import-time phase + +- Python imports modules. +- config.py executes top-level code. +- check_mysql_and_setup() runs during import. + +1. Runtime phase + +- Flask app object handles HTTP requests. +- Route functions execute per request. +- Templates are rendered into HTML responses. + +Why this distinction matters: + +- Import-time code runs once when process starts. +- Runtime code runs for every request. +- Heavy or risky operations in import-time code can delay startup. +- This project intentionally accepts some startup work to make local setup easier. + +### 10.2 Request Lifecycle (Step-by-Step) + +For one HTTP request, Flask does roughly this: + +1. Accept incoming URL and method. +2. Find matching route function. +3. Build request object (query params, form values, headers). +4. Execute route Python code. +5. Route either: + +- returns render_template(...) +- returns redirect(...) +- returns string/response body + +1. Flask sends response to browser. + +In this project: + +- Read routes usually query ORM then render template. +- Write routes usually validate inputs, mutate ORM objects, commit, then redirect. + +Why redirect after writes: + +- Prevents duplicate form submissions on page refresh. +- Follows Post/Redirect/Get pattern. + +### 10.3 SQLAlchemy Relationship Behavior (Why append Works) + +You will see patterns like: + +- user.posts.append(post) +- subforum.posts.append(post) + +Why this works: + +- SQLAlchemy tracks object relationships in memory. +- When db.session.commit() runs, SQLAlchemy computes needed foreign keys and INSERT/UPDATE statements. + +Benefit: + +- Route code stays object-oriented and readable. + +Risk to know: + +- If you forget commit, changes stay only in memory and are lost after request ends. + +### 10.4 Transaction and Error Behavior + +Each write route commits once after all changes are prepared. + +Current behavior: + +- No explicit rollback calls in routes. +- If commit throws, request errors and Flask returns stack trace in debug mode. + +Why that is acceptable here: + +- Project is educational/small. +- Simpler code is easier for beginners. + +What production would add: + +- try/except around commits +- session rollback on failure +- structured error responses/logging + +### 10.5 Data Model Design Choices + +User table: + +- unique username/email protects identity collisions. +- password_hash stores derived hash, never plain password. + +Post table: + +- links to one user and one subforum. +- title uses bounded String for index friendliness. + +Subforum table: + +- parent_id enables hierarchy (tree-like structure). +- self-relationship allows child categories. + +Comment table: + +- links to one user and one post. + +Why this shape: + +- Matches forum domain naturally. +- Keeps read queries simple for common pages. + +### 10.6 Time String Helpers (get_time_string) + +Post and Comment include get_time_string() to display relative age. + +How it works: + +- compute now - postdate/commentdate +- convert seconds into months/days/hours/minutes buckets + +Design note: + +- It uses cached fields lastcheck/savedresponce declared at class level. +- In strict OO terms, this can behave like shared state unless overwritten per instance. + +Why you might revisit this later: + +- To avoid accidental shared caching behavior across instances. +- A safer pattern is pure calculation without mutable shared fields. + +### 10.7 Authentication Model and Security Notes + +What is secure now: + +- Passwords are hashed via werkzeug helpers. +- Session user loading uses Flask-Login. +- Protected routes use login_required decorator. + +What is simplified for this project: + +- Username 'admin' auto-flags admin status during signup. +- This is convenient but weak for real authorization controls. + +Production hardening ideas: + +- Separate admin promotion workflow. +- CSRF protection checks on forms. +- Rate limiting on login endpoint. +- Stronger password policy enforcement (valid_password currently not enforced). + +### 10.8 MySQL Deep Rationale + +This app uses MySQL with a specific local-first strategy. + +Why not rely only on manual SQL setup: + +- New teammates get blocked quickly. +- Setup docs drift over time. +- Team onboarding becomes inconsistent. + +Why check_mysql_and_setup exists: + +- Tries to self-heal common missing pieces. +- Gives explicit fixes when self-heal cannot run. + +Why admin connection attempts multiple methods: + +- MySQL installation method changes access pattern: + - socket path + - localhost TCP without password + - localhost TCP with root password + +Why app connects as zipchat_app and not root: + +- Principle of least privilege. +- Better blast-radius control. +- Cleaner separation between setup role and runtime role. + +Why String lengths matter in MySQL: + +- UNIQUE index on TEXT without key length fails. +- Bounded String columns avoid that DDL error. + +### 10.9 Template Rendering Model + +This app uses server-side templates (Jinja): + +- Python route returns data +- Jinja merges data into HTML +- Browser receives ready-to-display page + +Why this approach: + +- Easy to reason about for beginners. +- No separate frontend build toolchain. + +Tradeoff: + +- Less dynamic than SPA frameworks. +- More full-page reloads. + +### 10.10 Common Failure Modes and Meaning + +1. MySQL auth failure (1045) + +- Meaning: bad user/password or user host mismatch. + +1. Unknown database (1049) + +- Meaning: DB name exists in config but not in MySQL. + +1. Can't connect (2003) + +- Meaning: MySQL process not reachable on host/port. + +1. DDL/index errors when creating tables + +- Meaning: model definitions incompatible with MySQL rules. + +Troubleshooting order: + +1. Confirm MySQL is running. +2. Confirm DB_* values. +3. Confirm user privileges. +4. Re-run startup and read first error message, not only bottom stack trace. + +### 10.11 Code Quality and Maintainability Observations + +Current strengths: + +- Small, readable structure. +- Clear route to template mapping. +- Straightforward ORM model design. + +Current weak spots to track: + +- Duplicate import line in routes.py. +- Mixed tab/space style in some files. +- Broad except Exception in setup code. +- Some helper functions return raw HTML strings. + +Safe improvement path: + +1. Keep behavior same. +2. Refactor one module at a time. +3. Add tests around auth + create post/comment flows. +4. Introduce migrations (Alembic/Flask-Migrate) for schema evolution. + +### 10.12 Mental Model for New Contributors + +If you are changing feature behavior, follow this mental checklist: + +1. Which URL handles this behavior? +2. Which route function owns that URL? +3. Which model objects are read or written? +4. Which template renders the result? +5. Which validation rules gate the write? +6. Where is commit called? +7. How would this fail if DB/auth/config changed? + +If you can answer those seven questions, you understand both how and why the script works for that feature. + +### 10.13 FAQ-Style Deep Answers + +Q: Why does config.py do work instead of only storing constants? +A: To improve local startup reliability and reduce manual setup friction for developers. + +Q: Is import-time DB setup always best practice? +A: Not always. It is a practical choice here for simplicity. In larger systems, setup is often moved to explicit migration/deployment commands. + +Q: Why not use SQLite for simplicity? +A: Team chose MySQL. MySQL better matches multi-user server deployments and privilege models. + +Q: Why are writes followed by redirect instead of rendering directly? +A: Redirect reduces accidental duplicate form submissions and keeps URL state clean. + +Q: Why keep both beginner and in-depth sections in one guide? +A: Team members have different experience levels. One file supports fast onboarding and deeper maintenance knowledge. diff --git a/MYSQL_MIGRATION_GUIDE.md b/MYSQL_MIGRATION_GUIDE.md new file mode 100644 index 0000000..16bbb38 --- /dev/null +++ b/MYSQL_MIGRATION_GUIDE.md @@ -0,0 +1,154 @@ +# MySQL Migration and Startup Guide + +This guide explains what we changed to move the app to MySQL, why the changes were needed, and how the app starts now. It is written for a team handoff, so it gives a quick summary first and then the deeper details. + +## Quick Overview + +- The app used to depend on SQLite-style assumptions. +- We moved the database config to MySQL and added PyMySQL support. +- We changed the launcher so the app starts with sensible defaults instead of stopping on missing environment variables. +- We fixed the schema so MySQL can create the tables cleanly. +- We added startup checks so the app can create the database and app user when MySQL access is available. + +In short: the app now starts, connects to MySQL, creates its tables, and seeds the default forum structure without the user having to understand the setup details first. + +## What We Changed + +### 1. Database configuration moved to MySQL + +The core database settings live in `config.py`. That file now builds a MySQL connection string using: + +- `DB_USER` +- `DB_PASSWORD` +- `DB_HOST` +- `DB_PORT` +- `DB_NAME` + +If those environment variables are not provided, the app uses defaults that match the project setup: + +- user: `zipchat_app` +- password: `password` +- host: `127.0.0.1` +- port: `3306` +- database: `ZipChat` + +This means a teammate can clone the repo and run the launcher without having to edit config files first. + +### 2. The launcher no longer hard-stops on missing password input + +`run.sh` used to exit when `DB_PASSWORD` was missing. That made startup brittle because the app could not even get to its own error handling. + +Now `run.sh`: + +- creates the virtual environment if needed +- installs requirements +- sets MySQL environment defaults +- provides a default DB password if one is not exported +- starts Flask + +That makes the startup path more forgiving for new users and for team members running the app locally. + +### 3. The schema was adjusted for MySQL rules + +MySQL does not allow unique indexes on plain `TEXT` columns without a key length. That is why `db.create_all()` originally failed on the `User` table. + +We changed the model fields that need uniqueness or fixed sizes: + +- `User.username` became a bounded string +- `User.email` became a bounded string +- `User.password_hash` became a bounded string +- `Post.title` became a bounded string +- `Subforum.title` became a bounded string + +That was the key schema fix that allowed MySQL table creation to succeed. + +### 4. Startup now tries to prepare MySQL automatically + +`config.py` now includes startup helpers that: + +- try to connect to MySQL as an admin user on the local machine +- create the `ZipChat` database if it is missing +- create the `zipchat_app` user if it is missing +- grant that user access to the database +- verify that the app credentials can connect + +This is a best-effort bootstrap. If MySQL admin access is unavailable on a specific machine, the code prints clear instructions instead of failing silently. + +### 5. MySQL driver support was added + +The app uses PyMySQL to talk to MySQL, and MySQL 9 can use `caching_sha2_password` authentication. That means the Python environment also needs the `cryptography` package. + +Without `cryptography`, the connection can fail during authentication even if the database settings are otherwise correct. + +## How Startup Works Now + +The current startup sequence is: + +1. `bash ./run.sh` is executed. +2. The script creates or reuses `.venv`. +3. Dependencies are installed. +4. Environment defaults are set for the app. +5. Flask starts with `forum.app`. +6. `forum/app.py` imports the Flask app factory. +7. `config.py` runs the MySQL setup checks. +8. MySQL admin access is attempted. +9. The database and app user are created if possible. +10. The app connects as `zipchat_app`. +11. `db.create_all()` creates tables. +12. The default subforums are seeded if needed. +13. The site starts listening on the configured port. + +That flow is why the app now feels more like a single command startup instead of a manual setup checklist. + +## Why Each Change Was Necessary + +### MySQL needed more explicit setup than SQLite + +SQLite is very forgiving because it stores data in a local file and usually does not require credentials. MySQL is stricter because it expects: + +- a running server +- a database name +- a user account +- a password or admin path +- permissions on the target database + +That is why a SQLite-style setup can work in development but fail once the backend is switched to MySQL. + +### The app needed a real default path + +Before the cleanup, startup depended on the user remembering environment variables and database details. That is brittle for a team workflow. + +The new defaults mean the repo itself now contains the expected values, and environment variables are only needed for overrides. + +### The models needed to match MySQL rules + +The ORM models are not just app code. They define the actual SQL table structure. If a field is too open-ended for a unique constraint, MySQL rejects the table creation. + +Changing `TEXT` to bounded `String` fields made the schema portable and allowed table creation to work consistently. + +## What A New Teammate Should Know + +- Start the app with `bash ./run.sh` from the project root. +- The app now carries sane defaults for the MySQL connection. +- The first startup may print MySQL setup warnings if local admin access is not available. +- If the database already exists, startup should proceed directly to table creation and app launch. +- If you want to override credentials, export the environment variables before running the launcher. + +## Important Files + +- `run.sh`: launch script and runtime defaults +- `config.py`: MySQL setup logic and SQLAlchemy configuration +- `forum/models.py`: database schema definitions +- `forum/app.py`: app startup, table creation, and seed data +- `requirements.txt`: Python dependencies, including PyMySQL and cryptography + +## Final Result + +The app now has a simpler onboarding story: + +- run one command +- get a MySQL-backed Flask app +- have the database schema created automatically +- use a predictable app user and database name + +That is the version of the project you can hand to a teammate without asking them to understand the whole migration history first. diff --git a/PROJECT_FLOW.md b/PROJECT_FLOW.md new file mode 100644 index 0000000..2a00379 --- /dev/null +++ b/PROJECT_FLOW.md @@ -0,0 +1,304 @@ +# CircusCircus Project Flow + +This document explains how the current project fits together at two levels: +- quick overview for orientation +- deeper walkthrough of the runtime logic and data flow + +It is written so someone new to Flask can follow the project without reading all source files first. + +## Quick Overview + +- Tech stack: Flask + Flask-Login + Flask-SQLAlchemy + Jinja templates. +- Entry point: `forum/app.py` creates the running app and root route. +- App factory: `forum/__init__.py` builds and configures the Flask app. +- Configuration: `config.py` provides secret key and SQLAlchemy settings. +- Data models: `forum/models.py` defines User, Post, Subforum, Comment. +- Request handlers: `forum/routes.py` handles auth, browsing, posting, comments. +- Account helpers: `forum/user.py` contains username/password utility checks. +- Templates: `forum/templates/` render pages for index, subforums, posts, login. +- Startup script: `run.sh` runs the app using Flask. + +## How Startup Works + +1. Flask starts with module `forum.app` (configured by `FLASK_APP`). +2. `forum/app.py` calls `create_app()` from `forum/__init__.py`. +3. `create_app()`: + - creates Flask app + - loads `Config` from `config.py` + - registers blueprint `rt` from `forum/routes.py` + - initializes SQLAlchemy via `db.init_app(app)` + - creates DB tables with `db.create_all()` inside app context +4. Back in `forum/app.py`: + - Flask-Login is configured + - `user_loader` is registered to restore users from session IDs + - tables are ensured again and initial subforums are seeded if empty + +Note: table creation happens in both `forum/__init__.py` and `forum/app.py`. That is safe, but redundant. + +## Beginner Map: What You Are Looking At + +If you open this project and feel lost, use this sequence: + +1. Open `config.py` to see app-wide settings. +2. Open `forum/__init__.py` to see how Flask is created and wired. +3. Open `forum/app.py` to see login setup, seed data setup, and the home route. +4. Open `forum/routes.py` to see the main user actions (login, browse, post, comment). +5. Open `forum/models.py` to understand what data is stored and how tables connect. +6. Open `forum/templates/` to see what each route renders. + +This order moves from "how app starts" to "what app does" to "how page looks". + +## URL and Request Flow + +### Root and browsing + +- `GET /` in `forum/app.py`: + - fetches top-level subforums (`parent_id is None`) + - renders `subforums.html` + +- `GET /subforum?sub=` in `forum/routes.py`: + - loads the target subforum + - loads recent posts for that subforum (up to 50, newest first) + - loads child subforums + - builds breadcrumb HTML path with `generateLinkPath()` + - renders `subforum.html` + +- `GET /viewpost?post=` in `forum/routes.py`: + - loads post and comments (newest comments first) + - builds breadcrumb path from post's subforum + - renders `viewpost.html` + +### Authentication + +- `POST /action_login`: + - reads username/password from form + - finds user by username + - verifies hash with `user.check_password()` + - logs in with Flask-Login or returns login page with errors + +- `GET /action_logout` (login required): + - logs current user out + - redirects to root + +- `POST /action_createaccount`: + - validates username format and uniqueness + - validates email uniqueness + - creates `User` (password stored as hash) + - auto-logs in and redirects to root + +### Content creation + +- `GET /addpost?sub=` (login required): + - verifies subforum exists + - renders `createpost.html` + +- `POST /action_post?sub=` (login required): + - validates title/content length + - creates `Post` + - links post to current user and subforum + - commits and redirects to `viewpost` + +- `POST/GET /action_comment?post=` (login required): + - verifies post exists + - creates `Comment` + - links comment to current user and post + - commits and redirects to `viewpost` + +## Deep Logic Walkthrough + +This section explains how the system behaves internally, not just which files exist. + +### 1. App factory and global objects + +- `db` is created once in `forum/models.py` as a global SQLAlchemy object. +- `create_app()` in `forum/__init__.py` binds that `db` object to the Flask app instance. +- Route registration happens by attaching the routes blueprint (`rt`) to the app. +- Flask-Login setup happens in `forum/app.py`, where `LoginManager` is attached to the app. + +Result: every route can use `db`, model queries, and `current_user` with one shared app context. + +### 2. Database initialization and seed logic + +- On startup, tables are created if missing. +- Then the app checks whether any `Subforum` rows exist. +- If none exist, `init_site()` seeds a default category tree. +- `add_subforum()` avoids duplicate titles at the same tree level. + +Result: first run creates a usable forum structure automatically. + +### 3. Authentication lifecycle + +- Account creation stores a hashed password only. +- Login route checks plain password against hash. +- On success, Flask-Login stores user identity in session. +- On later requests, `load_user(userid)` converts session ID back into a `User` row. +- Logout clears that session identity. + +Result: auth state is cookie/session based, with DB lookup per request as needed. + +### 4. Read flow for forum pages + +- Forum index route reads top-level subforums. +- Subforum route reads: + - one subforum by ID + - posts under that subforum + - direct child subforums + - breadcrumb path text +- View-post route reads: + - one post by ID + - comments for that post + - breadcrumb path from that post's subforum + +Result: each page query is focused and template-driven. + +### 5. Write flow for posts and comments + +- Create-post route validates title/content length. +- If invalid, template is re-rendered with error messages. +- If valid, a `Post` is created and attached to: + - current user + - selected subforum +- Create-comment route does the same pattern for `Comment`: + - verify post exists + - create comment + - attach to current user and post +- Each write ends with `db.session.commit()` and redirect. + +Result: writes are simple transaction-style operations with immediate redirect. + +### 6. Why relationships are used heavily + +- Instead of manually assigning every foreign key field, code often appends objects to relationships. +- Example pattern: + - `user.posts.append(post)` + - `subforum.posts.append(post)` +- SQLAlchemy fills key IDs and persists links on commit. + +Result: route code stays short and object-oriented. + +## End-to-End Request Trace Examples + +These traces show exactly what happens in common user actions. + +### Trace A: User signs up, then lands on home page + +1. Browser submits signup form to `POST /action_createaccount`. +2. Route reads username, email, password from `request.form`. +3. Helper checks run: + - username format + - username uniqueness + - email uniqueness +4. If any check fails: + - route renders login template with error list + - DB is unchanged +5. If checks pass: + - `User` object created (password hashed) + - user row committed + - user logged in with Flask-Login + - redirect to `/` +6. `GET /` loads top-level subforums and renders index template. + +### Trace B: Logged-in user creates a post + +1. User opens `GET /addpost?sub=`. +2. Route confirms target subforum exists. +3. Create-post form renders. +4. User submits form to `POST /action_post?sub=`. +5. Route validates title/content length. +6. If invalid, route re-renders form with validation messages. +7. If valid: + - `Post` object is created + - post attached to selected subforum and current user + - session commit persists post row and foreign keys + - redirect to `GET /viewpost?post=` +8. Post detail page queries post/comments and renders. + +### Trace C: Logged-in user comments on a post + +1. User submits comment form to `POST /action_comment?post=`. +2. Route verifies post exists. +3. Route creates `Comment` with content and current timestamp. +4. Comment is linked to current user and target post. +5. Commit persists comment row and keys. +6. Redirect goes back to `GET /viewpost?post=`. +7. Page reload now includes the new comment at top (newest first). + +## How to Read Logic Quickly During Maintenance + +When trying to understand a behavior, follow this mini-checklist: + +1. Identify route function handling the URL. +2. List which model queries it runs. +3. Check which helper validators it calls. +4. Check what template it renders or redirect it returns. +5. Confirm where commit happens for data writes. + +Using this method keeps debugging predictable across the whole codebase. + +## Data Model and Relationships + +Defined in `forum/models.py`: + +- `User` + - fields: id, username, password_hash, email, admin + - relationships: one-to-many with Post and Comment + - methods: `check_password()` + +- `Subforum` + - fields: id, title, description, parent_id, hidden + - relationships: + - self-referential child subforums via `subforums` + - one-to-many with Post + +- `Post` + - fields: id, title, content, user_id, subforum_id, postdate + - relationships: one-to-many with Comment + - helper: `get_time_string()` for relative-time labels + +- `Comment` + - fields: id, content, postdate, user_id, post_id + - helper: `get_time_string()` for relative-time labels + +## Validation and Utility Logic + +From `forum/user.py` and `forum/models.py`: + +- Username regex allows 4-40 characters from `[a-zA-Z0-9!@#%&]`. +- Password regex allows 6-40 characters from same set. +- `username_taken()` and `email_taken()` query existing users. +- `valid_title()` and `valid_content()` enforce post size bounds. +- `error()` returns a simple red HTML error string. +- `generateLinkPath()` builds breadcrumb HTML by walking up parent subforums. + +## Session and Login State + +- Flask-Login tracks authenticated users in session cookies. +- `load_user(userid)` in `forum/app.py` maps session user IDs to `User` rows. +- Routes requiring auth use `@login_required`. + +## Templates and Rendering Role + +- `subforums.html`: forum index page +- `subforum.html`: single subforum view + child subforums + post list +- `viewpost.html`: post detail + comments +- `createpost.html`: post creation form +- `login.html`: login/signup form +- `layout.html` and `header.html`: shared structure and navigation + +## Important Notes About Current Logic + +- Redundant DB initialization: `db.create_all()` is called in two places. +- Error responses are plain HTML snippets, not dedicated error templates. +- Some routes trust query parameters exist and are valid integers. +- Relative-time cache fields (`lastcheck`, `savedresponce`) are in-memory only and reset per process. + +## Practical Mental Model + +Think of the app as four layers: + +1. Config layer (`config.py`): constants for app and database. +2. App wiring (`forum/__init__.py`, `forum/app.py`): create app, attach DB/login, seed initial data. +3. Domain layer (`forum/models.py`, `forum/user.py`): schema, validation helpers, utility functions. +4. Delivery layer (`forum/routes.py` + templates): map HTTP requests to queries, updates, and rendered HTML. + +When debugging, start at the route, then trace into model queries and helper functions, then verify template rendering inputs. diff --git a/Procfile b/Procfile deleted file mode 100644 index e8ccc30..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn forum.app:app diff --git a/README.md b/README.md index cf8b121..08683e2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ # CircusCircus + This is a minimal forum written in python with Flask. It supports only the bare minumum of features to allow discussions, including user accounts, threads, and comments. On first run, the default subforums will be created. Although custom subforums are not supported through any user interface, it is possible to modify forum/setup.py to create custom subforums. +## Team Guide + +If you want the full explanation of the MySQL migration, startup flow, and schema changes, read [MYSQL_MIGRATION_GUIDE.md](/Users/ethan/Projects/CircusCircus/MYSQL_MIGRATION_GUIDE.md). + +For normal local startup, run: + +```bash +bash ./run.sh +``` + ## Create a Github Organization - create an org @@ -36,10 +47,10 @@ This currently puts a sqlite3 db in the /tmp directory. (use atleast python 3.11) ``` -$ python3.11 -m venv venv -$ source venv/bin/activate -$ pip install -r requirements.txt -$ ./run.sh +python3.11 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +./run.sh ``` and it should appear on port 5000 diff --git a/config.py b/config.py index da78fea..a41c9e0 100644 --- a/config.py +++ b/config.py @@ -1,18 +1,26 @@ -""" -Flask configuration variables. -""" +"""Flask configuration and MySQL bootstrap helpers.""" + from os import environ, path basedir = path.abspath(path.dirname(__file__)) -# load_dotenv(path.join(basedir, '.env')) + class Config: - """Set Flask configuration from .env file.""" - # General Config - SECRET_KEY = 'kristofer' - FLASK_APP = 'forum.app' + """Configuration values consumed by Flask and Flask-SQLAlchemy.""" + + SECRET_KEY = environ.get("SECRET_KEY", "kristofer") + FLASK_APP = "forum.app" + UPLOAD_FOLDER = path.join(basedir, "forum", "static", "uploads") + ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "mp4", "mov", "webm"} + + DB_USER = environ.get("DB_USER", "zipchat_app") + DB_PASSWORD = environ.get("DB_PASSWORD", "password") + DB_HOST = environ.get("DB_HOST", "127.0.0.1") + DB_PORT = environ.get("DB_PORT", "3306") + DB_NAME = environ.get("DB_NAME", "ZipChat") - # Database - SQLALCHEMY_DATABASE_URI = 'sqlite:///circuscircus.db' + SQLALCHEMY_DATABASE_URI = ( + f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) SQLALCHEMY_ECHO = False SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/forum/.DS_Store b/forum/.DS_Store new file mode 100644 index 0000000..5b80eae Binary files /dev/null and b/forum/.DS_Store differ diff --git a/forum/Reactions.py b/forum/Reactions.py new file mode 100644 index 0000000..e39db69 --- /dev/null +++ b/forum/Reactions.py @@ -0,0 +1,32 @@ +#import post.py +from flask import request, redirect, Blueprint, request, redirect +from flask_login import current_user +from flask import request, redirect, Blueprint, request, redirect +from flask_login import current_user +from flask_login.utils import login_required +import datetime +from .models import db + +rt_react = Blueprint('rt_react', __name__) + +class Reaction(db.Model): + id = db.Column(db.Integer, primary_key=True) + kind = db.Column(db.String(10)) # 'up', 'down', 'heart' + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + post_id = db.Column(db.Integer, db.ForeignKey('post.id')) + +@rt_react.route('/action_react', methods=['POST']) +@login_required +def action_react(): + post_id = int(request.form['post_id']) + kind = request.form['kind'] + if kind not in ('up', 'down', 'heart'): + return redirect('/') + existing = Reaction.query.filter_by(post_id=post_id, user_id=current_user.id, kind=kind).first() + if existing: + db.session.delete(existing) + else: + r = Reaction(kind=kind, user_id=current_user.id, post_id=post_id) + db.session.add(r) + db.session.commit() + return redirect('/viewpost?post=' + str(post_id)) diff --git a/forum/__init__.py b/forum/__init__.py index c10b0f3..a7d393e 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -1,21 +1,34 @@ from flask import Flask -from forum.routes import rt +from .routes import rt +from .post import post_rt +from .subforum import subforum_rt +from .Reactions import rt_react +from .messages import rt_messages +from .user import auth_bp +from .user_setting import settings_bp def create_app(): """Construct the core application.""" app = Flask(__name__, instance_relative_config=False) app.config.from_object('config.Config') - # I think more blueprints might be used to break routes up into things like + #I think more blueprints might be used to break routes up into things like # post_routes # subforum_routes # etc + # Register the main routes blueprint. app.register_blueprint(rt) + app.register_blueprint(post_rt) + app.register_blueprint(subforum_rt) + app.register_blueprint(rt_react) + app.register_blueprint(rt_messages) + app.register_blueprint(auth_bp) + app.register_blueprint(settings_bp) # Set globals - from forum.models import db + from .models import db db.init_app(app) with app.app_context(): - # Add some routes + # Create tables on startup so the app can run against a fresh database. db.create_all() return app diff --git a/forum/app.py b/forum/app.py index 4d8a828..8a9cb16 100644 --- a/forum/app.py +++ b/forum/app.py @@ -1,25 +1,40 @@ +import os +import sys + +if __package__ in (None, ""): + # Allow running this file directly with `python forum/app.py`. + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from flask import render_template from flask_login import LoginManager -from forum.models import Subforum, db, User +from sqlalchemy import inspect, text +from .models import db +from .subforum import Subforum +from .user import User +from .user_setting import UserSettings -from . import create_app +from forum import create_app +# Build the Flask app using the package factory. app = create_app() -app.config['SITE_NAME'] = 'Schooner' -app.config['SITE_DESCRIPTION'] = 'a schooner forum' +# Simple metadata used by the templates and app config. +app.config['SITE_NAME'] = 'ZipChat' +app.config['SITE_DESCRIPTION'] = 'a Zip Code Wilmington forum' app.config['FLASK_DEBUG'] = 1 def init_site(): + # Create the default forum structure on first run. print("creating initial subforums") - admin = add_subforum("Forum", "Announcements, bug reports, and general discussion about the forum belongs here") - add_subforum("Announcements", "View forum announcements here",admin) - add_subforum("Bug Reports", "Report bugs with the forum here", admin) - add_subforum("General Discussion", "Use this subforum to post anything you want") - add_subforum("Other", "Discuss other things here") + admin = add_subforum("Forum", "Announcements, bug reports, and general discussion about the forum belongs here", protected=True) + add_subforum("Announcements", "View forum announcements here", admin, protected=True) + add_subforum("Bug Reports", "Report bugs with the forum here", admin, protected=True) + add_subforum("General Discussion", "Use this subforum to post anything you want", protected=True) + add_subforum("Other", "Discuss other things here", protected=True) -def add_subforum(title, description, parent=None): +def add_subforum(title, description, parent=None, protected=False): + # Avoid duplicate subforums at the same level. sub = Subforum(title, description) + sub.protected = protected if parent: for subforum in parent.subforums: if subforum.title == title: @@ -35,23 +50,101 @@ def add_subforum(title, description, parent=None): db.session.commit() return sub + +def _sql_literal(value): + # Convert Python default values into SQL literals for ALTER TABLE statements. + if value is None: + return "NULL" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return str(value) + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + + +def migrate_legacy_user_settings(): + # Backfill UserSettings rows for existing users using the privacy column, + # and migrate post visibility from the old boolean to the new string column. + inspector = inspect(db.engine) + existing_tables = set(inspector.get_table_names()) + + if "user" in existing_tables and "user_settings" in existing_tables: + for user in User.query.all(): + if user.settings is not None: + continue + profile_visibility = user.privacy if user.privacy in ("public", "private") else "public" + user.settings = UserSettings(profile_visibility=profile_visibility) + db.session.commit() + + if "post" in existing_tables: + post_columns = {col["name"] for col in inspector.get_columns("post")} + if "private" in post_columns and "visibility" in post_columns: + with db.engine.begin() as conn: + conn.execute(text( + "UPDATE post SET visibility = 'private' WHERE private = TRUE AND visibility = 'public'" + )) + + +def ensure_model_schema_compatibility(): + # Keep existing MySQL tables compatible with current and future model columns. + inspector = inspect(db.engine) + existing_tables = set(inspector.get_table_names()) + + with db.engine.begin() as conn: + for mapper in db.Model.registry.mappers: + table = mapper.local_table + table_name = table.name + if table_name not in existing_tables: + continue + + existing_columns = {col["name"] for col in inspector.get_columns(table_name)} + for column in table.columns: + if column.name in existing_columns: + continue + if column.primary_key: + continue + + column_type = column.type.compile(dialect=db.engine.dialect) + nullability = "NULL" if column.nullable else "NOT NULL" + + default_sql = "" + if column.default is not None and getattr(column.default, "is_scalar", False): + default_sql = f" DEFAULT {_sql_literal(column.default.arg)}" + + sql = ( + f"ALTER TABLE `{table_name}` " + f"ADD COLUMN `{column.name}` {column_type} {nullability}{default_sql}" + ) + conn.execute(text(sql)) + +# Flask-Login needs a loader so it can restore the current user from the session. login_manager = LoginManager() login_manager.init_app(app) @login_manager.user_loader def load_user(userid): + # Look up the full User row for the stored session ID. return User.query.get(userid) with app.app_context(): - db.create_all() # TODO this may be redundant + # Create tables if needed, then seed the database the first time it runs. + db.create_all() + ensure_model_schema_compatibility() + migrate_legacy_user_settings() if not Subforum.query.all(): init_site() @app.route('/') def index(): + # Show only top-level subforums on the home page. subforums = Subforum.query.filter(Subforum.parent_id == None).order_by(Subforum.id) return render_template("subforums.html", subforums=subforums) +if __name__ == "__main__": + app.run(debug=True, port=8000) + + diff --git a/forum/messages.py b/forum/messages.py new file mode 100644 index 0000000..8acf9c0 --- /dev/null +++ b/forum/messages.py @@ -0,0 +1,46 @@ +from flask import Blueprint, render_template, request, redirect +from flask_login import login_required, current_user +from .models import db +import datetime +from .user import User + +class Messages(db.Model): + id = db.Column(db.Integer, primary_key=True) + content = db.Column(db.Text) + postdate = db.Column(db.DateTime) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) + recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) + read = db.Column(db.Boolean, default=False) + sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_messages') + recipient = db.relationship('User', foreign_keys=[recipient_id], backref='received_messages') + +rt_messages = Blueprint('rt_messages', __name__, template_folder='templates') + +@rt_messages.route('/messages') +@login_required +def messages(): + sender_filter = request.args.get('sender') + query = Messages.query.filter_by(recipient_id=current_user.id) + if sender_filter: + sender = User.query.filter_by(username=sender_filter).first() + if sender: + query = query.filter_by(sender_id=sender.id) + msgs = query.order_by(Messages.id.desc()).all() + senders = User.query.join(Messages, Messages.sender_id == User.id).filter(Messages.recipient_id == current_user.id).distinct().all() + return render_template('messages.html', messages=msgs, senders=senders, current_sender=sender_filter) + +@rt_messages.route('/action_message', methods=['POST']) +@login_required +def action_message(): + recipient = User.query.filter_by(username=request.form['recipient']).first() + if not recipient: + return render_template('messages.html', errors=["That username does not exist."], messages=Messages.query.filter_by(recipient_id=current_user.id).order_by(Messages.id.desc()).all(), senders=User.query.join(Messages, Messages.sender_id == User.id).filter(Messages.recipient_id == current_user.id).distinct().all(), current_sender=None) + allow_messages = recipient.settings.allow_messages if recipient.settings else True + if not allow_messages: + return render_template('messages.html', errors=["This user is not accepting direct messages."], messages=Messages.query.filter_by(recipient_id=current_user.id).order_by(Messages.id.desc()).all(), senders=User.query.join(Messages, Messages.sender_id == User.id).filter(Messages.recipient_id == current_user.id).distinct().all(), current_sender=None) + msg = Messages(content=request.form['content'], postdate=datetime.datetime.now(), + sender_id=current_user.id, recipient_id=recipient.id) + db.session.add(msg) + db.session.commit() + return redirect('/messages') + diff --git a/forum/models.py b/forum/models.py index 8add9ae..9d52276 100644 --- a/forum/models.py +++ b/forum/models.py @@ -3,139 +3,151 @@ from flask_login import UserMixin import datetime -# create db here so it can be imported (with the models) into the App object. +# Shared SQLAlchemy object used by the app factory and all models. from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() -#OBJECT MODELS -class User(UserMixin, db.Model): - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.Text, unique=True) - password_hash = db.Column(db.Text) - email = db.Column(db.Text, unique=True) - admin = db.Column(db.Boolean, default=False) - posts = db.relationship("Post", backref="user") - comments = db.relationship("Comment", backref="user") - - def __init__(self, email, username, password): - self.email = email - self.username = username - self.password_hash = generate_password_hash(password) - def check_password(self, password): - return check_password_hash(self.password_hash, password) - -class Post(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text) - content = db.Column(db.Text) - comments = db.relationship("Comment", backref="post") - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - postdate = db.Column(db.DateTime) - - #cache stuff - lastcheck = None - savedresponce = None - def __init__(self, title, content, postdate): - self.title = title - self.content = content - self.postdate = postdate - def get_time_string(self): - #this only needs to be calculated every so often, not for every request - #this can be a rudamentary chache - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - - seconds = diff.total_seconds() - print(seconds) - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - - return self.savedresponce - -class Subforum(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.Text, unique=True) - description = db.Column(db.Text) - subforums = db.relationship("Subforum") - parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) - posts = db.relationship("Post", backref="subforum") - path = None - hidden = db.Column(db.Boolean, default=False) - def __init__(self, title, description): - self.title = title - self.description = description - -class Comment(db.Model): - id = db.Column(db.Integer, primary_key=True) - content = db.Column(db.Text) - postdate = db.Column(db.DateTime) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - post_id = db.Column(db.Integer, db.ForeignKey("post.id")) - - lastcheck = None - savedresponce = None - def __init__(self, content, postdate): - self.content = content - self.postdate = postdate - def get_time_string(self): - #this only needs to be calculated every so often, not for every request - #this can be a rudamentary chache - now = datetime.datetime.now() - if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: - self.lastcheck = now - else: - return self.savedresponce - - diff = now - self.postdate - seconds = diff.total_seconds() - if seconds / (60 * 60 * 24 * 30) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" - elif seconds / (60 * 60 * 24) > 1: - self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" - elif seconds / (60 * 60) > 1: - self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" - elif seconds / (60) > 1: - self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" - else: - self.savedresponce = "Just a moment ago!" - return self.savedresponce +# Database models +# class User(UserMixin, db.Model): +# # Store account information and ownership of posts/comments. +# id = db.Column(db.Integer, primary_key=True) +# username = db.Column(db.String(80), unique=True, nullable=False) +# password_hash = db.Column(db.String(255), nullable=False) +# email = db.Column(db.String(255), unique=True, nullable=False) +# admin = db.Column(db.Boolean, default=False) +# posts = db.relationship("Post", backref="user") + +# def __init__(self, email, username, password): +# # Save the hashed password instead of the plain text password. +# self.email = email +# self.username = username +# self.password_hash = generate_password_hash(password) + +# def check_password(self, password): +# # Compare a password guess against the stored hash. +# return check_password_hash(self.password_hash, password) + +# Post is defined in post.py; imported here after db is ready to avoid +# circular imports while keeping Post in its own module. +# from .post import Post # noqa: E402 +# class Post(db.Model): +# # Store one forum post and link it to a user and subforum. +# id = db.Column(db.Integer, primary_key=True) +# title = db.Column(db.String(140), nullable=False) +# content = db.Column(db.Text) +# comments = db.relationship("Comment", backref="post") +# user_id = db.Column(db.Integer, db.ForeignKey('user.id')) +# subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) +# postdate = db.Column(db.DateTime) + +# # Simple in-memory cache for human-readable time labels. +# lastcheck = None +# savedresponce = None + +# def __init__(self, title, content, postdate): +# self.title = title +# self.content = content +# self.postdate = postdate + +# def get_time_string(self): +# # Only recalculate the label every 30 seconds. +# now = datetime.datetime.now() +# if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: +# self.lastcheck = now +# else: +# return self.savedresponce + +# diff = now - self.postdate + +# seconds = diff.total_seconds() +# if seconds / (60 * 60 * 24 * 30) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" +# elif seconds / (60 * 60 * 24) > 1: +# self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" +# elif seconds / (60 * 60) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" +# elif seconds / (60) > 1: +# self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" +# else: +# self.savedresponce = "Just a moment ago!" + +# return self.savedresponce + +# class Subforum(db.Model): +# # Represent a forum category and its optional child subforums. +# id = db.Column(db.Integer, primary_key=True) +# title = db.Column(db.Text, unique=True) +# description = db.Column(db.Text) +# subforums = db.relationship("Subforum") +# parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) +# posts = db.relationship("Post", backref="subforum") +# path = None +# hidden = db.Column(db.Boolean, default=False) + +# def __init__(self, title, description): +# self.title = title +# self.description = description + +# class Comment(db.Model): +# # Store a comment attached to a post and authored by a user. +# id = db.Column(db.Integer, primary_key=True) +# content = db.Column(db.Text, nullable=False) +# postdate = db.Column(db.DateTime) +# user_id = db.Column(db.Integer, db.ForeignKey('user.id')) +# post_id = db.Column(db.Integer, db.ForeignKey("post.id")) + +# lastcheck = None +# savedresponce = None + +# def __init__(self, content, postdate): +# self.content = content +# self.postdate = postdate + +# def get_time_string(self): +# # Only recalculate the label every 30 seconds. +# now = datetime.datetime.now() +# if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: +# self.lastcheck = now +# else: +# return self.savedresponce + +# diff = now - self.postdate +# seconds = diff.total_seconds() +# if seconds / (60 * 60 * 24 * 30) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" +# elif seconds / (60 * 60 * 24) > 1: +# self.savedresponce = " " + str(int(seconds / (60* 60 * 24))) + " days ago" +# elif seconds / (60 * 60) > 1: +# self.savedresponce = " " + str(int(seconds / (60 * 60))) + " hours ago" +# elif seconds / (60) > 1: +# self.savedresponce = " " + str(int(seconds / 60)) + " minutes ago" +# else: +# self.savedresponce = "Just a moment ago!" +# return self.savedresponce def error(errormessage): return "" + errormessage + "" -def generateLinkPath(subforumid): - links = [] - subforum = Subforum.query.filter(Subforum.id == subforumid).first() - parent = Subforum.query.filter(Subforum.id == subforum.parent_id).first() - links.append("" + subforum.title + "") - while parent is not None: - links.append("" + parent.title + "") - parent = Subforum.query.filter(Subforum.id == parent.parent_id).first() - links.append("Forum Index") - link = "" - for l in reversed(links): - link = link + " / " + l - return link - - -#Post checks +# def generateLinkPath(subforumid): +# links = [] +# subforum = Subforum.query.filter(Subforum.id == subforumid).first() +# parent = Subforum.query.filter(Subforum.id == subforum.parent_id).first() +# links.append("" + subforum.title + "") +# while parent is not None: +# links.append("" + parent.title + "") +# parent = Subforum.query.filter(Subforum.id == parent.parent_id).first() +# links.append("Forum Index") +# link = "" +# for l in reversed(links): +# link = link + " / " + l +# return link + + +# Post validation helpers def valid_title(title): return len(title) > 4 and len(title) < 140 + def valid_content(content): return len(content) > 10 and len(content) < 5000 diff --git a/forum/post.py b/forum/post.py new file mode 100644 index 0000000..d631568 --- /dev/null +++ b/forum/post.py @@ -0,0 +1,165 @@ +import datetime +import os +from flask import Blueprint, render_template, request, redirect, url_for, current_app, send_from_directory +from flask_login import current_user +from flask_login.utils import login_required +# from httpx import post +from werkzeug.utils import secure_filename +from .models import db, valid_content, valid_title, error +from .user import User +# import httpx + +post_rt = Blueprint('post_routes', __name__, template_folder='templates') + + +class Post(db.Model): + # Store a forum post or a reply. Top-level posts have parent_id=None; + # replies point to their parent via parent_id. + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.Text, nullable=True) + upload_file = db.Column(db.String(255), nullable=True) + content = db.Column(db.Text) + # private = db.Column(db.Boolean, default=False) + visibility = db.Column(db.String(20), nullable=False, default="public") + postdate = db.Column(db.DateTime) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + subforum_id = db.Column(db.Integer, db.ForeignKey('subforum.id'), nullable=True) + parent_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) + replies = db.relationship("Post", backref=db.backref('parent_post', remote_side='Post.id')) + reactions = db.relationship('Reaction', backref='post') + + # Simple in-memory cache for human-readable time labels. + lastcheck = None + savedresponse = None + + # def __init__(self, title=None, content=None, postdate=None, upload_file=None, private=False): + def __init__(self, title=None, content=None, postdate=None, upload_file=None, visibility="public"): + self.title = title + self.content = content + self.postdate = postdate + self.upload_file = upload_file + # self.private = private + self.visibility = visibility + + def get_time_string(self): + # Recalculate every 30 seconds to avoid inaccurate time labels + now = datetime.datetime.now() + if self.lastcheck is None or (now - self.lastcheck).total_seconds() > 30: + self.lastcheck = now + else: + return self.savedresponse + + diff = now - self.postdate + seconds = diff.total_seconds() + if seconds / (60 * 60 * 24 * 30) > 1: + self.savedresponse = " " + str(int(seconds / (60 * 60 * 24 * 30))) + " months ago" + elif seconds / (60 * 60 * 24) > 1: + self.savedresponse = " " + str(int(seconds / (60 * 60 * 24))) + " days ago" + elif seconds / (60 * 60) > 1: + self.savedresponse = " " + str(int(seconds / (60 * 60))) + " hours ago" + elif seconds / (60) > 1: + self.savedresponse = " " + str(int(seconds / 60)) + " minutes ago" + else: + self.savedresponse = "Just a moment ago!" + return self.savedresponse + + +# Routes are defined below after Post so they can reference it directly. +# Import Subforum here (after Post is defined) to avoid circular imports. +from .subforum import Subforum, generateLinkPath # noqa: E402 + + +@post_rt.route('/uploads/') +def uploaded_file(filename): + return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename) + +@post_rt.route('/addpost') +@login_required +def addpost(): + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + default_visibility = "public" + if current_user.is_authenticated and current_user.settings: + default_visibility = current_user.settings.post_visibility or "public" + return render_template("createpost.html", subforum=subforum, default_visibility=default_visibility) + +@post_rt.route('/viewpost') +def viewpost(): + postid = int(request.args.get("post")) + post = Post.query.filter(Post.id == postid).first() + if not post: + return error("That post does not exist!") + # if post.private and (not current_user.is_authenticated or post.user_id != current_user.id): + if post.visibility == "private" and not current_user.is_authenticated: + return redirect("/loginform") + subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) + comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) + return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + +@post_rt.route('/action_comment', methods=['POST', 'GET']) +@login_required +def comment(): + post_id = int(request.args.get("post")) + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + content = request.form['content'] + reply = Post(content=content, postdate=datetime.datetime.now()) + reply.parent_id = post_id + current_user.posts.append(reply) + db.session.commit() + return redirect("/viewpost?post=" + str(post_id)) + +@post_rt.route('/action_post', methods=['POST']) +@login_required +def action_post(): + subforum_id = int(request.args.get("sub")) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return redirect(url_for("routes.index")) + + user = current_user + title = request.form['title'] + content = request.form['content'] + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(content): + errors.append("Post must be between 10 and 5000 characters long!") + retry = True + if retry: + return render_template("createpost.html", subforum=subforum, errors=errors, default_visibility=request.form.get("visibility", "public")) + # private = 'private' in request.form + visibility = request.form.get("visibility", "public") + if visibility not in ("public", "private"): + visibility = "public" + file = request.files.get('upload_file') + filename = None + if file and file.filename: + filename = secure_filename(file.filename) + upload_folder = current_app.config['UPLOAD_FOLDER'] + os.makedirs(upload_folder, exist_ok=True) + file.save(os.path.join(upload_folder, filename)) + post = Post(title=title, content=content, postdate=datetime.datetime.now(), upload_file=filename, visibility=visibility) + subforum.posts.append(post) + user.posts.append(post) + db.session.commit() + return redirect("/viewpost?post=" + str(post.id)) + +@post_rt.route('/action_delete_post', methods=['POST']) +@login_required +def action_delete_post(): + post_id = int(request.form['post_id']) + post = Post.query.filter(Post.id == post_id).first() + if not post: + return error("That post does not exist!") + if post.user_id != current_user.id and not current_user.admin: + return error("You do not have permission to delete this post.") + Post.query.filter(Post.parent_id == post_id).delete() + db.session.delete(post) + db.session.commit() + return redirect("/subforum?sub=" + str(post.subforum_id)) \ No newline at end of file diff --git a/forum/routes.py b/forum/routes.py index 75993e5..0e4b9ab 100644 --- a/forum/routes.py +++ b/forum/routes.py @@ -3,145 +3,209 @@ from flask_login.utils import login_required import datetime from flask import Blueprint, render_template, request, redirect, url_for -from forum.models import User, Post, Comment, Subforum, valid_content, valid_title, db, generateLinkPath, error -from forum.user import username_taken, email_taken, valid_username +from .models import valid_content, valid_title, db, error +from .post import Post +from .subforum import Subforum, generateLinkPath +from .user import username_taken, email_taken, valid_username, User -## -# This file needs to be broken up into several, to make the project easier to work on. -## +# Route handlers for login, browsing, and content creation. +# The app is small enough to keep in one blueprint for now. rt = Blueprint('routes', __name__, template_folder='templates') -@rt.route('/action_login', methods=['POST']) -def action_login(): - 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")) - subforum = Subforum.query.filter(Subforum.id == subforum_id).first() - if not subforum: - return error("That subforum does not exist!") - posts = Post.query.filter(Post.subforum_id == subforum_id).order_by(Post.id.desc()).limit(50) - if not subforum.path: - subforumpath = generateLinkPath(subforum.id) - - subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() - return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) - -@rt.route('/loginform') -def loginform(): - 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)) +# @rt.route('/action_login', methods=['POST']) +# def action_login(): +# # Read login form values and authenticate the user. +# username = request.form['username'] +# password = request.form['password'] +# user = User.query.filter(User.username == username).first() +# if user and user.check_password(password): +# login_user(user) +# else: +# errors = [] +# errors.append("Username or password is incorrect!") +# return render_template("login.html", errors=errors) +# return redirect("/") + +# @login_required +# @rt.route('/action_logout') +# def action_logout(): +# # End the current session and send the user back home. +# logout_user() +# return redirect("/") + +# @rt.route('/action_createaccount', methods=['POST']) +# def action_createaccount(): +# # Validate signup data, create the user, then log them in. +# username = request.form['username'] +# password = request.form['password'] +# email = request.form['email'] +# errors = [] +# retry = False +# if username_taken(username): +# errors.append("Username is already taken!") +# retry = True +# if email_taken(email): +# errors.append("An account already exists with this email!") +# retry = True +# if not valid_username(username): +# errors.append("Username is not valid!") +# retry = True +# # if not valid_password(password): +# # errors.append("Password is not valid!") +# # retry = True +# if retry: +# return render_template("login.html", errors=errors) +# user = User(email, username, password) +# if user.username == "admin": +# user.admin = True +# db.session.add(user) +# db.session.commit() +# login_user(user) +# return redirect("/") + + +# @rt.route('/subforum') +# def subforum(): +# # Show one subforum, its posts, and its child subforums. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return error("That subforum does not exist!") +# posts = Post.query.filter( +# Post.subforum_id == subforum_id, +# (Post.private == False) | (Post.user_id == current_user.id if current_user.is_authenticated else False) +# ).order_by(Post.id.desc()).limit(50) +# subforumpath = subforum.path or generateLinkPath(subforum.id) + +# subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() +# return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) + +# @rt.route('/loginform') +# def loginform(): +# # Render the shared login and signup page. +# return render_template("login.html") + + +# @login_required +# @rt.route('/addpost') +# def addpost(): +# # Show the new post form for the selected subforum. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return error("That subforum does not exist!") + +# return render_template("createpost.html", subforum=subforum) + +# @rt.route('/viewpost') +# def viewpost(): +# # Show one post and its comments. +# postid = int(request.args.get("post")) +# post = Post.query.filter(Post.id == postid).first() +# if not post: +# return error("That post does not exist!") +# subforumpath = post.subforum.path or generateLinkPath(post.subforum.id) +# # Newest replies appear first for easier reading. +# comments = Post.query.filter(Post.parent_id == postid).order_by(Post.id.desc()) +# return render_template("viewpost.html", post=post, path=subforumpath, comments=comments) + +# @login_required +# @rt.route('/action_comment', methods=['POST', 'GET']) +# def comment(): +# # Create a comment, attach it to the user and post, then save it. +# post_id = int(request.args.get("post")) +# post = Post.query.filter(Post.id == post_id).first() +# if not post: +# return error("That post does not exist!") +# content = request.form['content'] +# postdate = datetime.datetime.now() +# reply = Post(content, postdate) +# reply.parent_id = post_id +# current_user.posts.append(reply) +# db.session.commit() +# return redirect("/viewpost?post=" + str(post_id)) + +# @login_required +# @rt.route('/action_post', methods=['POST']) +# def action_post(): +# # Validate a new post before saving it. +# subforum_id = int(request.args.get("sub")) +# subforum = Subforum.query.filter(Subforum.id == subforum_id).first() +# if not subforum: +# return redirect(url_for("index")) + +# user = current_user +# title = request.form['title'] +# content = request.form['content'] +# # Collect validation errors first so the form can be shown again if needed. +# errors = [] +# retry = False +# if not valid_title(title): +# errors.append("Title must be between 4 and 140 characters long!") +# retry = True +# if not valid_content(content): +# errors.append("Post must be between 10 and 5000 characters long!") +# retry = True +# if retry: +# return render_template("createpost.html", subforum=subforum, errors=errors) +# post = Post(content, datetime.datetime.now(), title=title) +# subforum.posts.append(post) +# user.posts.append(post) +# db.session.commit() +# return redirect("/viewpost?post=" + str(post.id)) + +# @login_required +# @rt.route('/createsubforum', methods=['GET', 'POST']) +# def create_subforum_page(): +# # Only admins can create subforums. +# if not current_user.admin: +# return error("Only administrators can create subforums!") + +# # Get the parent subforum if specified +# parent_id = request.args.get('parent') or request.form.get('parent_id') +# parent_subforum = None +# if parent_id: +# try: +# parent_id = int(parent_id) +# parent_subforum = Subforum.query.get(parent_id) +# except (ValueError, TypeError): +# pass + +# if request.method == 'POST': +# title = request.form['title'] +# description = request.form['description'] + +# # Validate input +# errors = [] +# retry = False +# if not valid_title(title): +# errors.append("Title must be between 4 and 140 characters long!") +# retry = True +# if not valid_content(description): +# errors.append("Description must be between 10 and 5000 characters long!") +# retry = True + +# if retry: +# subforums = Subforum.query.filter(Subforum.parent_id == None).all() +# return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) + +# # Create the new subforum +# subforum = Subforum(title, description) + +# # Add to parent if specified +# if parent_subforum: +# parent_subforum.subforums.append(subforum) + +# db.session.add(subforum) +# db.session.commit() + +# if parent_subforum: +# return redirect("/subforum?sub=" + str(parent_subforum.id)) +# else: +# return redirect("/") + +# # GET request - show the form +# subforums = Subforum.query.filter(Subforum.parent_id == None).all() +# return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) diff --git a/forum/static/js.txt b/forum/static/js.txt new file mode 100644 index 0000000..e4526f1 --- /dev/null +++ b/forum/static/js.txt @@ -0,0 +1,7 @@ +function submitPost() { + const input = document.getElementById("composeInput"); + if (!input.value.trim()) return; + + alert("Post submitted: " + input.value); + input.value = ""; +} \ No newline at end of file diff --git a/forum/static/style.css b/forum/static/style.css index 2dd2e2e..b3bcbee 100644 --- a/forum/static/style.css +++ b/forum/static/style.css @@ -1,191 +1,918 @@ -.page{ - font-family: Verdana; - font-style: normal; - font-size: 14pt; - - margin-left: auto; - margin-right: auto; - margin-top: 1%; - width: 80%; - - padding-left: 3%; - padding-right: 3%; - -} -.header{ - border: 1px solid black; - overflow: hidden; - vertical-align: middle; - padding: 0.5%; - margin-bottom: none; -} -.content{ - margin-top: 0.5%; -} -.login{ - width: 25%; - float: right; - text-align: center; - -} -.loginbox{ - margin-right: auto; - margin-left: auto; - text-align: center; - margin-top: 3%; -} -.loginelement{ - margin-top: 0.5%; -} -.logoutlink{ - padding-left: 0.5%; - font-size: 12pt; -} -.postsubmit{ - margin-left: auto; - margin-right: auto; - text-align: center; - -} -.subforumlisting{ - border-bottom: 1px solid gray; - padding: 1%; - margin-bottom: 1%; -} -.inputbox{ - padding: 0.25%; - margin-top: 0.5%; -} -.errors{ - border-bottom: 1px solid black; - margin-top: 2%; -} -.error{ - color: red; - font-weight: bold; -} -.subforumtitle{ - color: blue; - font-size: 15pt; - font-weight: bold; -} -.subforumdesc{ - font-size: 13pt; - font-style: italic; -} -.subforumheader{ - padding: 0.5%; - padding-left: 1%; - margin-top: none; - padding-bottom: none; - margin-bottom: 0.5%; - border: 1px solid black; - font-size: 15pt; -} -.subforumheadertitle{ - display: inline; - font-weight: bold; -} -.subforumheaderdesc{ - display: inline; -} -.post{ - padding: 1%; - border-bottom: 1px solid gray; -} -.postusername{ - font-style: italic; - font-size: 12pt; -} -.subforumheaderlink{ - font-style: italic; - font-size: 12pt; - margin-top: 0.5%; -} -.postinginfo{ - font-size: 11pt; - font-style: italic; -} -#nolinks{ - text-decoration: none; +:root { + --zc-green: #22c55e; + --zc-green-dark: #16a34a; + --zc-green-light: #bbf7d0; + --zc-green-pale: #f0fdf4; + --zc-blue: #3b82f6; + --zc-blue-dark: #1d4ed8; + --zc-blue-light: #bfdbfe; + --zc-blue-pale: #eff6ff; + --zc-bg: #f0fdf9; + --zc-surface: #ffffff; + --zc-border: #d1fae5; + --zc-text: #0f172a; + --zc-muted: #64748b; + --zc-subtle: #94a3b8; + --zc-radius: 16px; + --zc-radius-sm: 10px; + --zc-shadow: 0 4px 24px rgba(34, 197, 94, 0.1); + --zc-shadow-blue: 0 4px 24px rgba(59, 130, 246, 0.15); } -.noposts{ - padding: 1%; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + background: var(--zc-bg); + color: var(--zc-text); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 700px 500px at 0% 0%, rgba(34, 197, 94, 0.13) 0%, transparent 65%), + radial-gradient(ellipse 600px 450px at 100% 80%, rgba(59, 130, 246, 0.14) 0%, transparent 65%); +} + +@keyframes meshDrift { + from { + transform: scale(1) translate(0, 0); + } + to { + transform: scale(1.07) translate(24px, 16px); + } +} + +.page-wrap { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.zc-navbar { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(18px); + border-bottom: 1px solid var(--zc-green-light); + padding: 12px 24px; + display: flex; + align-items: center; + gap: 20px; +} + +.zc-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +.zc-logo-icon { + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 900; +} + +.zc-logo-text { + font-size: 20px; + font-weight: 800; + letter-spacing: 1px; +} + +.zc-logo-text span { + color: var(--zc-blue); +} + +.zc-nav-right { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; +} + +.zc-nav-links { + display: flex; + align-items: center; + gap: 4px; + margin-left: 12px; +} + +.zc-nav-link { + padding: 7px 16px; + border-radius: 22px; + font-size: 13px; + font-weight: 600; + color: var(--zc-muted); + border: 1.5px solid transparent; + transition: all 0.2s ease; + text-decoration: none; + cursor: pointer; + background: transparent; + white-space: nowrap; +} + +.zc-nav-link:hover, +.zc-nav-link.active { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.4); + color: var(--zc-green-dark); +:root { + --zc-green: #22c55e; + --zc-green-dark: #16a34a; + --zc-green-light: #bbf7d0; + --zc-green-pale: #f0fdf4; + --zc-blue: #3b82f6; + --zc-blue-dark: #1d4ed8; + --zc-blue-light: #bfdbfe; + --zc-blue-pale: #eff6ff; + --zc-bg: #f0fdf9; + --zc-surface: #ffffff; + --zc-border: #d1fae5; + --zc-text: #0f172a; + --zc-muted: #64748b; + --zc-subtle: #94a3b8; + --zc-radius: 16px; + --zc-radius-sm: 10px; + --zc-shadow: 0 4px 24px rgba(34, 197, 94, 0.1); + --zc-shadow-blue: 0 4px 24px rgba(59, 130, 246, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + background: var(--zc-bg); + color: var(--zc-text); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 700px 500px at 0% 0%, rgba(34, 197, 94, 0.13) 0%, transparent 65%), + radial-gradient(ellipse 600px 450px at 100% 80%, rgba(59, 130, 246, 0.14) 0%, transparent 65%); +} + +@keyframes meshDrift { + from { + transform: scale(1) translate(0, 0); + } + to { + transform: scale(1.07) translate(24px, 16px); + } +} + +.page-wrap { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.zc-navbar { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(18px); + border-bottom: 1px solid var(--zc-green-light); + padding: 12px 24px; + display: flex; + align-items: center; + gap: 20px; +} + +.zc-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +.zc-logo-icon { + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 900; +} + +.zc-logo-text { + font-size: 20px; + font-weight: 800; + letter-spacing: 1px; +} + +.zc-logo-text span { + color: var(--zc-blue); +} + +.zc-nav-right { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; +} + +.zc-nav-links { + display: flex; + align-items: center; + gap: 4px; + margin-left: 12px; +} + +.zc-nav-link { + padding: 7px 16px; + border-radius: 22px; + font-size: 13px; + font-weight: 600; + color: var(--zc-muted); + border: 1.5px solid transparent; + transition: all 0.2s ease; + text-decoration: none; + cursor: pointer; + background: transparent; + white-space: nowrap; +} + +.zc-nav-link:hover, +.zc-nav-link.active { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.4); + color: var(--zc-green-dark); +} + +.zc-nav-user { + font-weight: 700; + color: var(--zc-text); + +.zc-nav-user { + font-weight: 700; + color: var(--zc-text); +} + +.btn-zc-primary, +.btn-zc-ghost { + text-decoration: none; + font-size: 13px; + font-weight: 700; + border-radius: 22px; + padding: 8px 14px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-zc-primary { + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + border: none; +} + +.btn-zc-ghost { + color: var(--zc-muted); + border: 1px solid var(--zc-green-light); + background: #fff; +} + +.zc-hero { + background: linear-gradient(135deg, rgba(187, 247, 208, 0.55), rgba(191, 219, 254, 0.55)); + border-bottom: 1px solid var(--zc-green-light); + padding: 22px 32px; + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + +.btn-zc-primary, +.btn-zc-ghost { + text-decoration: none; + font-size: 13px; + font-weight: 700; + border-radius: 22px; + padding: 8px 14px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-zc-primary { + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + border: none; +} + +.btn-zc-ghost { + color: var(--zc-muted); + border: 1px solid var(--zc-green-light); + background: #fff; +} + +.zc-hero { + background: linear-gradient(135deg, rgba(187, 247, 208, 0.55), rgba(191, 219, 254, 0.55)); + border-bottom: 1px solid var(--zc-green-light); + padding: 22px 32px; + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} + +.zc-hero h1 { + margin: 0; + font-size: 26px; + font-weight: 800; + +.zc-hero h1 { + margin: 0; + font-size: 26px; + font-weight: 800; +} + +.zc-hero h1 span { + color: var(--zc-blue); +} + +.zc-hero p { + margin: 4px 0 0; + color: var(--zc-muted); + +.zc-hero h1 span { + color: var(--zc-blue); +} + +.zc-hero p { + margin: 4px 0 0; + color: var(--zc-muted); +} + +.zc-stats { + display: flex; + gap: 12px; + margin-left: auto; + flex-wrap: wrap; +} + +.zc-stat-pill { + background: rgba(255, 255, 255, 0.78); + border: 1.5px solid var(--zc-green-light); + border-radius: 14px; + padding: 10px 22px; + text-align: center; + +.zc-stats { + display: flex; + gap: 12px; + margin-left: auto; + flex-wrap: wrap; +} + +.zc-stat-pill { + background: rgba(255, 255, 255, 0.78); + border: 1.5px solid var(--zc-green-light); + border-radius: 14px; + padding: 10px 22px; + text-align: center; +} + +.zc-stat-num { + font-size: 22px; + font-weight: 800; + color: var(--zc-blue); + line-height: 1; + +.zc-stat-num { + font-size: 22px; + font-weight: 800; + color: var(--zc-blue); + line-height: 1; +} + +.zc-stat-lbl { + font-size: 11px; + color: var(--zc-muted); + margin-top: 3px; + font-weight: 600; + text-transform: uppercase; } -.subsubforums{ - border: 1px solid black; - padding: 0.5%; - margin-top: 0.5%; - padding-left: 1%; + +.zc-layout { + width: 100%; + max-width: 1260px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr; + gap: 24px; + padding: 24px 32px; + flex: 1; +} + +.glass-card { + background: rgba(255, 255, 255, 0.78); + border: 1px solid var(--zc-green-light); + border-radius: var(--zc-radius); + box-shadow: var(--zc-shadow); + padding: 14px; +} + +.zc-sidebar-label, +.zc-widget-title { + font-size: 10px; + font-weight: 800; + letter-spacing: 1.5px; + color: var(--zc-subtle); + text-transform: uppercase; + margin-bottom: 8px; +} + +.zc-sidebar-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--zc-radius-sm); + text-decoration: none; + color: var(--zc-muted); + font-size: 13px; + font-weight: 600; +} + +.zc-sidebar-link:hover { + background: rgba(34, 197, 94, 0.1); + color: var(--zc-green-dark); +} + +.zc-sidebar-link.active { + background: rgba(59, 130, 246, 0.1); + border-color: rgba(59, 130, 246, 0.35); + color: var(--zc-blue-dark); + +.zc-sidebar-link:hover { + background: rgba(34, 197, 94, 0.1); + color: var(--zc-green-dark); +} + +.zc-sidebar-link.active { + background: rgba(59, 130, 246, 0.1); + border-color: rgba(59, 130, 246, 0.35); + color: var(--zc-blue-dark); +} + +.zc-sidebar-badge { + margin-left: auto; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + font-size: 10px; + font-weight: 800; + padding: 2px 8px; + border-radius: 20px; + +.zc-sidebar-badge { + margin-left: auto; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + font-size: 10px; + font-weight: 800; + padding: 2px 8px; + border-radius: 20px; +} + +.zc-widget { + background: rgba(255, 255, 255, 0.75); + border: 1.5px solid var(--zc-green-light); + border-radius: var(--zc-radius); + padding: 16px 18px; + margin-bottom: 16px; +} + +.zc-dm-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 9px; + border-radius: var(--zc-radius-sm); + transition: all 0.18s ease; + margin-bottom: 4px; + border: 1.5px solid transparent; +} + +.zc-dm-row:hover { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.25); +} + +.zc-dm-name { + font-size: 13px; + font-weight: 700; + color: var(--zc-text); + +.zc-widget { + background: rgba(255, 255, 255, 0.75); + border: 1.5px solid var(--zc-green-light); + border-radius: var(--zc-radius); + padding: 16px 18px; + margin-bottom: 16px; +} + +.zc-dm-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 9px; + border-radius: var(--zc-radius-sm); + transition: all 0.18s ease; + margin-bottom: 4px; + border: 1.5px solid transparent; } -.subsubforumtitle{ - font-weight: bolder; - text-decoration: none; - font-size: 14pt; + +.zc-dm-row:hover { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.25); } -.subsubforumdesc{ - font-style: italic; - font-size: 12pt; + +.zc-dm-name { + font-size: 13px; + font-weight: 700; + color: var(--zc-text); } -.toplevelinfo{ - padding: 0.5%; - font-weight: bold; + +.zc-dm-badge { + width: 19px; + height: 19px; + background: var(--zc-blue); + border-radius: 50%; + color: #fff; + font-size: 10px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + +.zc-dm-badge { + width: 19px; + height: 19px; + background: var(--zc-blue); + border-radius: 50%; + color: #fff; + font-size: 10px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; } -.actualpost{ - padding: 1%; - border: 1px solid black; - border-collapse: collapse; - margin-bottom: 1%; + +.zc-muted-text { + margin: 0; + color: var(--zc-muted); + font-size: 13px; + line-height: 1.5; } -.actualposttitle{ - border-collapse: collapse; - border-bottom: 1px solid black; - padding: 1%; - font-size: 16pt; - font-weight: bold; + +.zc-feed { + min-width: 0; } -.postcontent{ - padding: 1%; + +.zc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; + +.zc-muted-text { + margin: 0; + color: var(--zc-muted); + font-size: 13px; + line-height: 1.5; } -.postposter{ - font-size: 12pt; - font-style: italic; - color: black; + +.zc-feed { + min-width: 0; } -.posttime{ - font-style: italic; - font-size: 11pt; + +.zc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; } -.whitespace{ - white-space: pre-line; + +.zc-section-title { + font-size: 18px; + font-weight: 800; + margin: 0; + display: flex; + align-items: center; + gap: 8px; + +.zc-section-title { + font-size: 18px; + font-weight: 800; + margin: 0; + display: flex; + align-items: center; + gap: 8px; } -.comments{ - border: 1px solid gray; - padding: 1%; - margin-top: 1%; + +.zc-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + +.zc-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.comment{ - border-bottom: 1px solid gray; + +.zc-card-title { + margin: 0; + font-size: 18px; + font-weight: 800; + +.zc-card-title { + margin: 0; + font-size: 18px; + font-weight: 800; } -.commentuser{ - display: inline; - font-weight: bold; + +.zc-card-title a { + text-decoration: none; + color: var(--zc-text); } -.commentcontent{ - display: inline; -} -.commenttime{ - font-size: 11pt; - font-style: italic; + +.zc-card-body { + color: var(--zc-muted); + margin: 10px 0; } +.zc-card-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; -.addcomment{ - display: none; - margin-right: auto; - margin-left: auto; - text-align: center; +.zc-card-title a { + text-decoration: none; + color: var(--zc-text); } -.varwidth{ - width: 50%; + +.zc-card-body { + color: var(--zc-muted); + margin: 10px 0; } + +.zc-card-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.zc-chip { + background: #ecfeff; + color: #0f766e; + border: 1px solid #99f6e4; + border-radius: 20px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; + +.zc-chip { + background: #ecfeff; + color: #0f766e; + border: 1px solid #99f6e4; + border-radius: 20px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; +} + +.zc-meta { + color: var(--zc-subtle); + font-size: 12px; + margin: 0; +} + +.zc-empty { + background: rgba(255, 255, 255, 0.8); + border: 1.5px dashed var(--zc-green-light); + border-radius: var(--zc-radius); + padding: 20px; + text-align: center; + color: var(--zc-muted); + +.zc-meta { + color: var(--zc-subtle); + font-size: 12px; + margin: 0; +} + +.zc-empty { + background: rgba(255, 255, 255, 0.8); + border: 1.5px dashed var(--zc-green-light); + border-radius: var(--zc-radius); + padding: 20px; + text-align: center; + color: var(--zc-muted); +} + +.zc-breadcrumb { + margin-bottom: 10px; + color: var(--zc-muted); + font-size: 13px; + +.zc-breadcrumb { + margin-bottom: 10px; + color: var(--zc-muted); + font-size: 13px; +} + +.errors { + width: min(1260px, 100% - 40px); + margin: 14px auto 0; + +.errors { + width: min(1260px, 100% - 40px); + margin: 14px auto 0; +} + +.error { + background: #fff1f2; + border: 1px solid #fecdd3; + border-radius: 10px; + padding: 10px 12px; + color: #be123c; + margin: 6px 0; + +.error { + background: #fff1f2; + border: 1px solid #fecdd3; + border-radius: 10px; + padding: 10px 12px; + color: #be123c; + margin: 6px 0; +} + +.zc-footer { + margin-top: auto; + padding: 16px 24px; + border-top: 1px solid var(--zc-green-light); + background: rgba(255, 255, 255, 0.78); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; +} + +.zc-footer-brand { + font-size: 16px; + font-weight: 800; +} + +.zc-footer-brand span { + color: var(--zc-blue); +} + +.zc-footer-copy { + color: var(--zc-subtle); + font-size: 12px; +} + +/* Existing template compatibility */ +.toplevelinfo, +.subforumheader, +.subforumlisting, +.post, +.actualpost, +.comment, +.loginbox, +.subsubforums, +.noposts, +.addcomment, +.comments { + background: rgba(255, 255, 255, 0.8); + border: 1px solid var(--zc-green-light); + border-radius: var(--zc-radius); + box-shadow: var(--zc-shadow); + padding: 14px 16px; + margin-bottom: 12px; +} + +.subforumtitle a, +.subsubforumtitle a, +.posttitle a, +.actualposttitle a { + color: var(--zc-blue-dark); + text-decoration: none; + font-weight: 800; +} + +.subforumdesc, +.subsubforumdesc, +.postcontent, +.commentcontent, +.postusername, +.commenttime, +.commentuser, +.date, +.posttime { + color: var(--zc-muted); +} + +.inputbox, +.loginelement, +textarea, +input[type='text'], +input[type='password'], +input[type='email'], +select { + width: 100%; + border: 1px solid var(--zc-green-light); + border-radius: 10px; + padding: 10px 12px; + font: inherit; + background: #fff; +} + +input[type='submit'], +input[type='button'], +button { + border: none; + background: linear-gradient(135deg, var(--zc-green), var(--zc-blue)); + color: #fff; + border-radius: 20px; + padding: 8px 14px; + font-weight: 700; +} +form[action="/action_react"] button { + background: none; + color: #000; + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px 10px; + font-size: 14pt; +} + +form[action="/action_react"] button:hover { + background: #eee; + color: #000; +} + +#nolinks { + text-decoration: none; + color: var(--zc-text); +} + +.adminusername { + color: #dc2626; +} + +@media (max-width: 992px) { + .zc-layout { + grid-template-columns: 1fr; + padding: 14px; + } + + .zc-sidebar-left, + .zc-sidebar-right { + display: none; + } + + .zc-nav-links { + display: none; + } +} \ No newline at end of file diff --git a/forum/static/uploads/.gitkeep b/forum/static/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/forum/subforum.py b/forum/subforum.py new file mode 100644 index 0000000..34af500 --- /dev/null +++ b/forum/subforum.py @@ -0,0 +1,159 @@ +from .models import db, valid_content, valid_title, error +from .post import Post + +class Subforum(db.Model): + # Represent a forum category and its optional child subforums. + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255), unique=True) + description = db.Column(db.Text) + subforums = db.relationship("Subforum") + parent_id = db.Column(db.Integer, db.ForeignKey('subforum.id')) + posts = db.relationship("Post", backref="subforum") + path = None + hidden = db.Column(db.Boolean, default=False) + protected = db.Column(db.Boolean, default=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + users = db.relationship("User", backref="subforum") + + def __init__(self, title, description): + self.title = title + self.description = description + +def generateLinkPath(subforumid): + links = [] + subforum = Subforum.query.filter(Subforum.id == subforumid).first() + parent = Subforum.query.filter(Subforum.id == subforum.parent_id).first() + links.append("" + subforum.title + "") + while parent is not None: + links.append("" + parent.title + "") + parent = Subforum.query.filter(Subforum.id == parent.parent_id).first() + links.append("Forum Index") + link = "" + for l in reversed(links): + link = link + " / " + l + return link + + +# Post validation helpers +def valid_title(title): + return len(title) > 4 and len(title) < 140 + +def valid_content(content): + return len(content) > 10 and len(content) < 5000 + + +#routes for subforum browsing and creation + +from flask import Blueprint, render_template, request, redirect +from flask_login import current_user, login_required +from flask_login.utils import login_required + +# Route handlers for login, browsing, and content creation. +# The app is small enough to keep in one blueprint for now. + +subforum_rt = Blueprint('subforum_routes', __name__, template_folder='templates') +@subforum_rt.route('/subforum') +def subforum(): + # Show one subforum, its posts, and its child subforums. + subforum_id = request.args.get("sub", type=int) + subforum = Subforum.query.filter(Subforum.id == subforum_id).first() + if not subforum: + return error("That subforum does not exist!") + # posts = Post.query.filter( + # Post.subforum_id == subforum_id, + # (Post.private == False) | (Post.user_id == current_user.id if current_user.is_authenticated else False) + # ).order_by(Post.id.desc()).limit(50) + posts = Post.query.filter(Post.subforum_id == subforum_id) + if not current_user.is_authenticated: + posts = posts.filter(Post.visibility == "public") + posts = posts.order_by(Post.id.desc()).limit(50) + subforumpath = subforum.path or generateLinkPath(subforum.id) + + subforums = Subforum.query.filter(Subforum.parent_id == subforum_id).all() + return render_template("subforum.html", subforum=subforum, posts=posts, subforums=subforums, path=subforumpath) + +@login_required +@subforum_rt.route('/createsubforum', methods=['GET', 'POST']) +def create_subforum_page(): + # Only admins can create subforums. + if not current_user.admin: + return error("Only administrators can create subforums!") + + # Get the parent subforum if specified + parent_id = request.args.get('parent') or request.form.get('parent_id') + parent_subforum = None + if parent_id: + try: + parent_id = int(parent_id) + parent_subforum = Subforum.query.get(parent_id) + except (ValueError, TypeError): + pass + + if request.method == 'POST': + title = request.form['title'] + description = request.form['description'] + + # Validate input + errors = [] + retry = False + if not valid_title(title): + errors.append("Title must be between 4 and 140 characters long!") + retry = True + if not valid_content(description): + errors.append("Description must be between 10 and 5000 characters long!") + retry = True + + if retry: + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum, errors=errors) + + # Create the new subforum + subforum = Subforum(title, description) + + # Add to parent if specified + if parent_subforum: + parent_subforum.subforums.append(subforum) + + db.session.add(subforum) + db.session.commit() + + if parent_subforum: + return redirect("/subforum?sub=" + str(parent_subforum.id)) + else: + return redirect("/") + + # GET request - show the form + subforums = Subforum.query.filter(Subforum.parent_id == None).all() + return render_template("createsubforum.html", subforums=subforums, parent_subforum=parent_subforum) + +@login_required +@subforum_rt.route('/deletesubforum', methods=['POST']) +def delete_subforum(): + # Only admins can delete subforums. + if not current_user.admin: + return Subforum.error("Only administrators can delete subforums!") + + subforum_id = request.form.get('subforum_id', type=int) + subforum = Subforum.query.get(subforum_id) + + if not subforum: + return Subforum.error("That subforum does not exist!") + + # Prevent deletion of protected subforums + if subforum.protected: + return Subforum.error("This subforum is protected and cannot be deleted!") + + # Prevent deletion if subforum has child subforums or posts + if subforum.subforums or subforum.posts: + return Subforum.error("Cannot delete a subforum that contains child subforums or posts!") + + parent_id = subforum.parent_id + db.session.delete(subforum) + db.session.commit() + + # Redirect back to parent or home + if parent_id: + return redirect("/subforum?sub=" + str(parent_id)) + else: + return redirect("/") + diff --git a/forum/templates/createpost.html b/forum/templates/createpost.html index 85947a6..3d5752e 100644 --- a/forum/templates/createpost.html +++ b/forum/templates/createpost.html @@ -9,13 +9,19 @@ You are posting to {{ subforum.title }} -
+

+
+ +
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/forum/templates/createsubforum.html b/forum/templates/createsubforum.html new file mode 100644 index 0000000..0635b74 --- /dev/null +++ b/forum/templates/createsubforum.html @@ -0,0 +1,31 @@ +{% extends 'layout.html' %} +{% block body %} + +
+

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

+ +
+ +
+

+ +
+

+ + {% if not parent_subforum %} +
+

+ {% else %} + + {% endif %} + + +
+
+ +{% endblock %} diff --git a/forum/templates/header.html b/forum/templates/header.html index 9403a0d..ec84e7a 100644 --- a/forum/templates/header.html +++ b/forum/templates/header.html @@ -1,8 +1,14 @@ -{{ config.SITE_NAME }}{% if config.SITE_DESCRIPTION %} - {% endif %} {{ config.SITE_DESCRIPTION }} -