diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index cc1857b8..8cd8998f 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -31,7 +31,7 @@ jobs: steps: - name: 'Mint identity token' id: 'mint_identity_token' - if: |- + if: -| ${{ vars.APP_ID }} uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: @@ -45,10 +45,10 @@ jobs: env: GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - MESSAGE: |- + MESSAGE: -| 🤖 Hi @${{ github.actor }}, I've received your request, and I'm working on it now! You can track my progress [in the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. REPOSITORY: '${{ github.repository }}' - run: |- + run: -| gh issue comment "${ISSUE_NUMBER}" \ --body "${MESSAGE}" \ --repo "${REPOSITORY}" @@ -61,6 +61,7 @@ jobs: id: 'gemini_pr_review' env: GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + GITHUB_PERSONAL_ACCESS_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' ISSUE_TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' ISSUE_BODY: '${{ github.event.pull_request.body || github.event.issue.body }}' PULL_REQUEST_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' @@ -79,54 +80,56 @@ jobs: use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' - settings: |- + settings: -| { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".gemini/telemetry.log" - }, - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v0.18.0" - ], - "includeTools": [ - "add_comment_to_pending_review", - "create_pending_pull_request_review", - "pull_request_read", - "submit_pending_pull_request_review" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" + "model": { + "maxSessionTurns": 25 + }, + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".gemini/telemetry.log" + }, + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v0.18.0" + ], + "includeTools": [ + "add_comment_to_pending_review", + "create_pending_pull_request_review", + "pull_request_read", + "submit_pending_pull_request_review" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}" + } + } + }, + "tools": { + "core": [ + "run_shell_command(cat)", + "run_shell_command(echo)", + "run_shell_command(grep)", + "run_shell_command(head)", + "run_shell_command(tail)" + ] } } - }, - "tools": { - "core": [ - "run_shell_command(cat)", - "run_shell_command(echo)", - "run_shell_command(grep)", - "run_shell_command(head)", - "run_shell_command(tail)" - ] - } - } prompt: '/gemini-review' + - name: 'Run Gemini security analysis review' uses: 'google-github-actions/run-gemini-cli@main' # ratchet:exclude id: 'gemini_security_analysis' env: GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + GITHUB_PERSONAL_ACCESS_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' ISSUE_TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' ISSUE_BODY: '${{ github.event.pull_request.body || github.event.issue.body }}' PULL_REQUEST_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' @@ -145,11 +148,11 @@ jobs: use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' - extensions: | + extensions: -| [ "https://github.com/gemini-cli-extensions/security.git" ] - settings: |- + settings: -| { "model": { "maxSessionTurns": 100 @@ -177,7 +180,7 @@ jobs: "submit_pending_pull_request_review" ], "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}" } } }, @@ -191,4 +194,4 @@ jobs: ] } } - prompt: '/security:analyze-github-pr' \ No newline at end of file + prompt: '/security:analyze-github-pr' diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..39a69e5d --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,58 @@ +# Implementation Plan - North Pole Wishlist + +## Phase 0: Git Setup +- [x] Check if the current directory is an initialized git repository. +- [x] If it is, create and checkout a new feature branch named "north-pole-wishlist". + +## Phase 1: Environment & Project Setup +- [x] Initialize the Python environment (virtualenv) and install Flask, Flask-SQLAlchemy, and Flask-Migrate. +- [x] Create `requirements.txt` with necessary dependencies. +- [x] Create the project structure (`app/`, `instance/`, `migrations/`). +- [x] Implement `config.py` with configuration classes (Development, Production) and environment variable handling. +- [x] Create the application factory in `app/__init__.py`. +- [x] Create a basic route in `app/routes.py` and a base template to verify the server runs. + +## Phase 2: Database Models & Migration +- [x] Configure `Flask-SQLAlchemy` in `app/__init__.py`. +- [x] Create `app/models.py` and define the `GiftIdea` model using SQLAlchemy 2.0 style. +- [x] Define the `Vote` model in `app/models.py`. +- [x] Define the `Comment` model in `app/models.py`. +- [x] Initialize database migrations (`flask db init`, `flask db migrate`, `flask db upgrade`) to create the SQLite database. +- [x] Create a seed script (`seed.py`) to populate the database with initial sample data for testing. + +## Phase 3: Core Backend Logic (Gift Management) +- [x] Implement the `POST /gift/new` route in `app/routes.py` for gift submission. +- [x] Create the form template `app/templates/create_gift.html`. +- [x] Implement input validation for gift submission (title length, category validation). +- [x] Implement the `GET /` route (Landing Page) to fetch and display gifts. +- [x] Implement sorting logic (Popular, Top Rated, New) and category filtering in the `GET /` route. + +## Phase 4: Frontend Implementation (Bootstrap 5 & Theming) +- [x] Setup `base.html` with Bootstrap 5 CDN and custom CSS file structure (`app/static/css/style.css`). +- [x] Implement the custom color palette (Santa Red, Forest Green, Snow White) in `style.css`. +- [x] Import and apply Google Fonts (*Mountains of Christmas*, *Lato/Roboto*). +- [x] Create or source the Hero Image (Santa's Sleigh) and add it to the Landing Page. +- [x] Style the Gift Cards to display title, description, category, and score. +- [x] Add festive icons (snowflakes/stars) for the rating display. + +## Phase 5: Engagement Features (Voting & Comments) +- [x] Implement the `POST /gift//vote` API endpoint to handle JSON payloads. +- [x] Add JavaScript to the frontend to handle AJAX voting requests and update the UI dynamically. +- [x] Implement the `POST /gift//comment` route to handle comment submission. +- [x] Create the comment section in the gift detail view (or modal). +- [x] Validate comment content (length check) and default author name logic. +- [x] Ensure comments are displayed in chronological order. + +## Phase 6: Final Polish & Cleanup +- [x] Review all pages for responsiveness on mobile devices. +- [x] Verify validation error messages are displayed correctly to the user (Flash messages). +- [x] Optimize database queries (e.g., eager loading relationships if needed). +- [x] Run a final manual test of the entire user flow: Submit Gift -> Vote -> Comment -> Filter/Sort. + +## Phase 7: Completion & Version Control +- [ ] Verify application functionality. +- [ ] Create a `README.md` file explaining the application functions, how to interact with them, the architecture, file breakdown and how to run and test it locally. +- [ ] Add all changes to the repository (`git add .`). +- [ ] Commit the changes (`git commit -m "Complete implementation of North Pole Wishlist"`). +- [ ] Push the feature branch to the remote repository, creating a branch with the same name in the remote repository, using the Gemini CLI github MCP server. +- [ ] Open a pull request for the feature branch using the Gemini CLI github MCP server, leave it open for review, don't merge it. diff --git a/README.md b/README.md index 75588e2d..ebe4e03f 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,52 @@ -# Spec-Driven Development w Gemini CLI - -This repo has some basic assets to experiment **Spec-Driven Development** using the Gemini CLI. You will act as a developer going from a raw Functional Specification to a deployed Pull Request in a single session. - -## Assets - -* `.gemini/commands/`: Contains configuration files for custom commands (`techspec`, `plan`, `build`). -* `GEMINI.md`: Contains project rules and guidelines. -* `.github/workflows`: Contains CI workflow. -* **No application code**. - -## Requirements - -The `GEMINI.md` configuration and custom commands require the following extensions: -* **Google Workspace** -* **Nano Banana** -* **GitHub** - ---- - -## Step 1: The Architect Phase (/techspec) - -**Goal:** Transform a Functional Spec (Google Doc) into a Technical Spec (Google Doc). - -1. **Command:** - ``` - /techspec "Name of your functional specs doc" "Your desired technology stack and requirements" - ``` - -2. **What Happens:** - * The agent searches your Drive for the doc. - * It reads the requirements. - * It generates a **Technical Specification** including Data Models, API Routes, and Architecture based on your inputs. - * **Output:** It creates a *new* Google Doc titled "Technical Specification - Application name" and gives you the link. - ---- - -## Step 2: The Planning Phase (/plan) - -**Goal:** Break the Technical Spec down into an atomic Implementation Plan. - -1. **Command:** - ``` - /plan "Name of your Tech spec doc" - ``` - *(Use the exact name of the doc generated in Step 1)* - -2. **What Happens:** - * The agent reads the Tech Spec. - * It creates a local file `IMPLEMENTATION_PLAN.md`. - * It breaks the project into phases (e.g., Setup, Backend, Frontend, Polish). - * It defines the Git strategy. - ---- - -## Step 3: The Build Phase (/build) - -**Goal:** Execute the plan and write the code. - -1. **Command:** - ``` - /build IMPLEMENTATION_PLAN.md "Name of your Tech spec doc" - ``` - -2. **What Happens (Iterative):** - * **Execution:** The agent iterates through the plan, initializing the project structure and writing the application code. - * **Visuals:** It generates necessary visual assets (images, icons) as defined in the spec. - * **Progress:** It updates `IMPLEMENTATION_PLAN.md` as tasks are completed. - ---- - -## Step 4: Final Delivery - -**Goal:** Push the code and open a Pull Request. - -1. **Action:** - The `/build` command's final phase usually covers this, or you can manually instruct the agent to finalize the project. - -2. **What Happens:** - * The agent runs final checks (linting/formatting). - * It creates a `README.md` for the new application. - * It commits all changes. - * It pushes the feature branch to GitHub. - * It uses the GitHub extension to **Open a Pull Request**. - ---- - -## Summary of Commands - -| Step | Command | Input | Output | -| :--- | :--- | :--- | :--- | -| **1. Spec** | `/techspec` | Functional Doc (Drive) | Tech Spec (Drive) | -| **2. Plan** | `/plan` | Tech Spec (Drive) | `IMPLEMENTATION_PLAN.md` | -| **3. Build** | `/build` | Plan + Tech Spec | Code, Assets, App | \ No newline at end of file +# North Pole Wishlist + +A community-driven platform to discover, share, and curate the best holiday gift ideas. + +## Features + +- **Gift Ideas**: Submit and browse gift suggestions with categories. +- **Voting**: Rate gifts on a 5-snowflake scale ("Naughty or Nice"). +- **Comments**: Discuss gift ideas with the community. +- **Sorting & Filtering**: Find the best gifts by Popularity, Rating, or Category. +- **Festive Theme**: A fully immersive Christmas design. + +## Architecture + +- **Backend**: Python Flask +- **Database**: SQLite with SQLAlchemy 2.0 (ORM) +- **Frontend**: Bootstrap 5 + Jinja2 Templates +- **Theme**: Custom CSS with Google Fonts (*Mountains of Christmas*) + +## Setup & Run + +1. **Clone the repository** +2. **Create a virtual environment**: + ```bash + python3 -m venv venv + source venv/bin/activate + ``` +3. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` +4. **Initialize the database**: + ```bash + flask db upgrade + python seed.py # Optional: Seed with sample data + ``` +5. **Run the application**: + ```bash + python run.py + ``` +6. Open `http://127.0.0.1:5000` in your browser. + +## File Structure + +- `app/`: Application source code. + - `models.py`: Database models. + - `routes.py`: API and view routes. + - `templates/`: HTML templates. + - `static/`: CSS and Images. +- `migrations/`: Database migration scripts. +- `config.py`: Configuration settings. +- `run.py`: Entry point. diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 00000000..c8fc8101 Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/run.cpython-313.pyc b/__pycache__/run.cpython-313.pyc new file mode 100644 index 00000000..b5d78d8e Binary files /dev/null and b/__pycache__/run.cpython-313.pyc differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..64b40628 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,16 @@ +from flask import Flask +from config import DevelopmentConfig +from app.extensions import db, migrate + +def create_app(config_class=DevelopmentConfig): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + migrate.init_app(app, db) + + from app import models # Import models here to register them + from app import routes + app.register_blueprint(routes.bp) + + return app diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..97ed5b0a Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/extensions.cpython-313.pyc b/app/__pycache__/extensions.cpython-313.pyc new file mode 100644 index 00000000..ccb1a026 Binary files /dev/null and b/app/__pycache__/extensions.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..dfbf4c1c Binary files /dev/null and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/__pycache__/routes.cpython-313.pyc b/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 00000000..ff98a269 Binary files /dev/null and b/app/__pycache__/routes.cpython-313.pyc differ diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 00000000..7dabd70c --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,9 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass + +db = SQLAlchemy(model_class=Base) +migrate = Migrate() diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..a3d55fe2 --- /dev/null +++ b/app/models.py @@ -0,0 +1,47 @@ +from datetime import datetime, timezone +from typing import List, Optional +import sqlalchemy as sa +import sqlalchemy.orm as so +from app.extensions import db + +class GiftIdea(db.Model): + __tablename__ = 'gift_idea' + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + title: so.Mapped[str] = so.mapped_column(sa.String(100)) + description: so.Mapped[str] = so.mapped_column(sa.String(500)) + category: so.Mapped[str] = so.mapped_column(sa.String(50)) + created_at: so.Mapped[datetime] = so.mapped_column(default=lambda: datetime.now(timezone.utc)) + + votes: so.Mapped[List["Vote"]] = so.relationship(back_populates="gift") + comments: so.Mapped[List["Comment"]] = so.relationship(back_populates="gift") + + def __repr__(self): + return f'' + +class Vote(db.Model): + __tablename__ = 'vote' + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + gift_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('gift_idea.id')) + score: so.Mapped[int] = so.mapped_column() + created_at: so.Mapped[datetime] = so.mapped_column(default=lambda: datetime.now(timezone.utc)) + + gift: so.Mapped["GiftIdea"] = so.relationship(back_populates="votes") + + def __repr__(self): + return f'' + +class Comment(db.Model): + __tablename__ = 'comment' + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + gift_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('gift_idea.id')) + author_name: so.Mapped[str] = so.mapped_column(sa.String(100), default="Anonymous Elf") + content: so.Mapped[str] = so.mapped_column(sa.String(500)) + created_at: so.Mapped[datetime] = so.mapped_column(default=lambda: datetime.now(timezone.utc)) + + gift: so.Mapped["GiftIdea"] = so.relationship(back_populates="comments") + + def __repr__(self): + return f'' diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 00000000..d69313e8 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,130 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +import sqlalchemy as sa +from sqlalchemy.orm import selectinload +from app.extensions import db +from app.models import GiftIdea, Vote, Comment + +bp = Blueprint('main', __name__) + +@bp.route('/') +def index(): + category = request.args.get('category') + sort_by = request.args.get('sort', 'new') + + query = sa.select(GiftIdea).options(selectinload(GiftIdea.votes)) + + if category: + query = query.where(GiftIdea.category == category) + + # Sorting logic + if sort_by == 'popular': + # Sort by total number of votes + query = query.outerjoin(GiftIdea.votes).group_by(GiftIdea.id).order_by(sa.func.count(Vote.id).desc()) + elif sort_by == 'top_rated': + # Sort by average score + query = query.outerjoin(GiftIdea.votes).group_by(GiftIdea.id).order_by(sa.func.avg(Vote.score).desc()) + else: # 'new' + query = query.order_by(GiftIdea.created_at.desc()) + + gifts = db.session.scalars(query).all() + + # Calculate stats for each gift (can be optimized later) + gift_stats = {} + for gift in gifts: + total_votes = len(gift.votes) + avg_score = sum(v.score for v in gift.votes) / total_votes if total_votes > 0 else 0 + gift_stats[gift.id] = { + 'total_votes': total_votes, + 'avg_score': round(avg_score, 1) + } + + return render_template('index.html', gifts=gifts, gift_stats=gift_stats, current_category=category, current_sort=sort_by) + +@bp.route('/gift/new', methods=['GET', 'POST']) +def create_gift(): + if request.method == 'POST': + title = request.form.get('title') + description = request.form.get('description') + category = request.form.get('category') + + # Validation + if not title or len(title) > 100: + flash('Invalid title.', 'danger') + return redirect(url_for('main.create_gift')) + if not description or len(description) > 500: + flash('Invalid description.', 'danger') + return redirect(url_for('main.create_gift')) + + valid_categories = ["For Kids", "For Parents", "Stocking Stuffers", "DIY / Homemade", "Tech & Gadgets", "Decorations"] + if category not in valid_categories: + flash('Invalid category.', 'danger') + return redirect(url_for('main.create_gift')) + + new_gift = GiftIdea(title=title, description=description, category=category) + db.session.add(new_gift) + db.session.commit() + + flash('Gift idea submitted successfully!', 'success') + return redirect(url_for('main.index')) + + return render_template('create_gift.html') + +@bp.route('/gift/') +def gift_detail(id): + stmt = sa.select(GiftIdea).options(selectinload(GiftIdea.votes), selectinload(GiftIdea.comments)).where(GiftIdea.id == id) + gift = db.session.scalars(stmt).first() + + if not gift: + flash('Gift not found.', 'danger') + return redirect(url_for('main.index')) + + # Calculate stats + total_votes = len(gift.votes) + avg_score = sum(v.score for v in gift.votes) / total_votes if total_votes > 0 else 0 + gift_stats = { + 'total_votes': total_votes, + 'avg_score': round(avg_score, 1) + } + + # Sort comments: Oldest first + comments = sorted(gift.comments, key=lambda c: c.created_at) + + return render_template('gift_detail.html', gift=gift, gift_stats=gift_stats, comments=comments) + +@bp.route('/gift//vote', methods=['POST']) +def vote(id): + data = request.get_json() + score = data.get('score') + + if not score or score not in range(1, 6): + return {'error': 'Invalid score'}, 400 + + vote = Vote(gift_id=id, score=score) + db.session.add(vote) + db.session.commit() + + # Recalculate stats to return + gift = db.session.get(GiftIdea, id) + total_votes = len(gift.votes) + avg_score = sum(v.score for v in gift.votes) / total_votes if total_votes > 0 else 0 + + return { + 'new_average': round(avg_score, 1), + 'total_votes': total_votes + } + +@bp.route('/gift//comment', methods=['POST']) +def add_comment(id): + content = request.form.get('content') + author_name = request.form.get('author_name') or "Anonymous Elf" + + if not content or len(content) < 10 or len(content) > 500: + flash('Comment must be between 10 and 500 characters.', 'danger') + return redirect(url_for('main.gift_detail', id=id)) + + comment = Comment(gift_id=id, content=content, author_name=author_name) + db.session.add(comment) + db.session.commit() + + flash('Comment added!', 'success') + return redirect(url_for('main.gift_detail', id=id)) \ No newline at end of file diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 00000000..18f03fc5 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,96 @@ +:root { + --santa-red: #D42426; + --forest-green: #146B3A; + --snow-white: #F8F9FA; + --gold-accent: #FFD700; +} + +body { + background-color: var(--snow-white); + font-family: 'Lato', sans-serif; + color: #333; +} + +h1, h2, h3, h4, h5, h6, .navbar-brand { + font-family: 'Mountains of Christmas', cursive; + color: var(--santa-red); +} + +.navbar { + background-color: var(--forest-green) !important; + border-bottom: 5px solid var(--gold-accent); +} + +.navbar-brand, .nav-link { + color: var(--snow-white) !important; + font-size: 1.5rem; +} + +.nav-link:hover { + color: var(--gold-accent) !important; +} + +.btn-primary { + background-color: var(--santa-red); + border-color: var(--santa-red); +} + +.btn-primary:hover { + background-color: #b01b1d; + border-color: #b01b1d; +} + +.btn-outline-secondary { + color: var(--forest-green); + border-color: var(--forest-green); +} + +.btn-outline-secondary:hover, .btn-outline-secondary.active { + background-color: var(--forest-green); + color: var(--snow-white); +} + +.card { + border: 2px solid var(--forest-green); + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + transition: transform 0.2s; +} + +.card:hover { + transform: translateY(-5px); +} + +.card-title { + color: var(--santa-red); + font-weight: bold; +} + +.card-subtitle { + color: var(--forest-green) !important; +} + +.hero-section { + position: relative; + text-align: center; + color: white; + margin-bottom: 2rem; +} + +.hero-image { + width: 100%; + max-height: 400px; + object-fit: cover; + border-bottom: 5px solid var(--gold-accent); +} + +.hero-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(20, 107, 58, 0.7); /* Forest Green with opacity */ + padding: 20px; + border-radius: 10px; + border: 2px solid var(--gold-accent); +} diff --git a/app/static/img/hero.png b/app/static/img/hero.png new file mode 100644 index 00000000..600ee257 Binary files /dev/null and b/app/static/img/hero.png differ diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 00000000..84f1c396 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,39 @@ + + + + + + North Pole Wishlist + + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/create_gift.html b/app/templates/create_gift.html new file mode 100644 index 00000000..67f68599 --- /dev/null +++ b/app/templates/create_gift.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Submit a New Gift Idea

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/gift_detail.html b/app/templates/gift_detail.html new file mode 100644 index 00000000..580af960 --- /dev/null +++ b/app/templates/gift_detail.html @@ -0,0 +1,108 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

{{ gift.title }}

+
{{ gift.category }}
+

{{ gift.description }}

+ +
+ +
+
+ Rating: {{ gift_stats.avg_score }}/5 + ({{ gift_stats.total_votes }} votes) +
+
+ Rate this gift: + {% for i in range(1, 6) %} + + {% endfor %} +
+
+
+
+ + +
+
+

Comments

+
+
+ +
+
+ + +
+
+ + +
Min 10 chars, Max 500 chars.
+
+ +
+ + +
+ {% for comment in comments %} +
+
+
{{ comment.author_name }}
+ {{ comment.created_at.strftime('%Y-%m-%d %H:%M') }} +
+

{{ comment.content }}

+
+ {% else %} +
+ No comments yet. Be the first to say something! +
+ {% endfor %} +
+
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 00000000..d370bc60 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} + +{% block content %} +
+ Santa's Sleigh +
+

North Pole Wishlist

+

Discover and share the best holiday gifts!

+
+
+ +
+
+

Gift Ideas

+
+
+ +
+
+ +
+
+ +
+
+ +
+ {% for gift in gifts %} +
+
+
+
{{ gift.title }}
+
{{ gift.category }}
+

{{ gift.description }}

+
+ +
+
+ {% else %} +
+

No gift ideas found. Be the first to submit one!

+
+ {% endfor %} +
+{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 00000000..1fe51da0 --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +import os + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess-santa' + SQLALCHEMY_TRACK_MODIFICATIONS = False + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(os.getcwd(), 'instance', 'northpole.db') + +class ProductionConfig(Config): + DEBUG = False + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') + diff --git a/instance/northpole.db b/instance/northpole.db new file mode 100644 index 00000000..4b1c72fc Binary files /dev/null and b/instance/northpole.db differ diff --git a/migrations/README b/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/__pycache__/env.cpython-313.pyc b/migrations/__pycache__/env.cpython-313.pyc new file mode 100644 index 00000000..cae41e77 Binary files /dev/null and b/migrations/__pycache__/env.cpython-313.pyc differ diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..4c970927 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/__pycache__/a50e2c6cf41a_initial_migration.cpython-313.pyc b/migrations/versions/__pycache__/a50e2c6cf41a_initial_migration.cpython-313.pyc new file mode 100644 index 00000000..1a04e584 Binary files /dev/null and b/migrations/versions/__pycache__/a50e2c6cf41a_initial_migration.cpython-313.pyc differ diff --git a/migrations/versions/a50e2c6cf41a_initial_migration.py b/migrations/versions/a50e2c6cf41a_initial_migration.py new file mode 100644 index 00000000..2f5d94e5 --- /dev/null +++ b/migrations/versions/a50e2c6cf41a_initial_migration.py @@ -0,0 +1,54 @@ +"""Initial migration + +Revision ID: a50e2c6cf41a +Revises: +Create Date: 2025-12-11 12:12:25.170376 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a50e2c6cf41a' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('gift_idea', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.Column('category', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('gift_id', sa.Integer(), nullable=False), + sa.Column('author_name', sa.String(length=100), nullable=False), + sa.Column('content', sa.String(length=500), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['gift_id'], ['gift_idea.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('vote', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('gift_id', sa.Integer(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['gift_id'], ['gift_idea.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('vote') + op.drop_table('comment') + op.drop_table('gift_idea') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..58a2f777 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +alembic==1.17.2 +blinker==1.9.0 +click==8.3.1 +Flask==3.1.2 +Flask-Migrate==4.1.0 +Flask-SQLAlchemy==3.1.1 +itsdangerous==2.2.0 +Jinja2==3.1.6 +Mako==1.3.10 +MarkupSafe==3.0.3 +SQLAlchemy==2.0.45 +typing_extensions==4.15.0 +Werkzeug==3.1.4 diff --git a/run.py b/run.py new file mode 100644 index 00000000..3dc5f39f --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run() diff --git a/seed.py b/seed.py new file mode 100644 index 00000000..259d1e1d --- /dev/null +++ b/seed.py @@ -0,0 +1,41 @@ +from app import create_app, db +from app.models import GiftIdea, Vote, Comment + +app = create_app() + +def seed_data(): + with app.app_context(): + # Clear existing data + db.drop_all() + db.create_all() + + print("Creating Gift Ideas...") + g1 = GiftIdea(title="Retro Popcorn Maker", description="Perfect for movie nights! Makes delicious popcorn in minutes.", category="For Kids") + g2 = GiftIdea(title="Cozy Wool Socks", description="Keep your feet warm this winter with these hand-knitted socks.", category="Stocking Stuffers") + g3 = GiftIdea(title="Smart Home Hub", description="Control your lights and music with your voice.", category="Tech & Gadgets") + g4 = GiftIdea(title="Hand-painted Ornament", description="Beautiful glass ornament for the tree.", category="Decorations") + + db.session.add_all([g1, g2, g3, g4]) + db.session.commit() + + print("Adding Votes...") + v1 = Vote(gift_id=g1.id, score=5) + v2 = Vote(gift_id=g1.id, score=4) + v3 = Vote(gift_id=g2.id, score=5) + v4 = Vote(gift_id=g3.id, score=3) + + db.session.add_all([v1, v2, v3, v4]) + db.session.commit() + + print("Adding Comments...") + c1 = Comment(gift_id=g1.id, author_name="Santa Claus", content="Ho ho ho! A wonderful gift!") + c2 = Comment(gift_id=g1.id, author_name="Elf Buddy", content="I love popcorn!") + c3 = Comment(gift_id=g3.id, content="Does it work with Matter?") + + db.session.add_all([c1, c2, c3]) + db.session.commit() + + print("Database seeded successfully!") + +if __name__ == "__main__": + seed_data()