From 3698250934d56cd5f6ae15bb1b9764301a937d74 Mon Sep 17 00:00:00 2001 From: Giovanni Galloro Date: Tue, 9 Dec 2025 09:32:25 +0100 Subject: [PATCH 1/3] Complete implementation of North Pole Wishlist --- .gitignore | 4 + IMPLEMENTATION_PLAN.md | 57 ++++++++ README.md | 62 +++++++++ app/__init__.py | 21 +++ app/models.py | 59 ++++++++ app/routes.py | 127 +++++++++++++++++ app/static/js/main.js | 131 ++++++++++++++++++ app/templates/base.html | 84 +++++++++++ app/templates/index.html | 117 ++++++++++++++++ config.py | 9 ++ migrations/README | 1 + migrations/alembic.ini | 50 +++++++ migrations/env.py | 113 +++++++++++++++ migrations/script.py.mako | 24 ++++ .../881c18fb91d2_initial_migration.py | 54 ++++++++ requirements.txt | 5 + run.py | 6 + 17 files changed, 924 insertions(+) create mode 100644 .gitignore create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/static/js/main.js create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 config.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/881c18fb91d2_initial_migration.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..65b4c607 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +app.db +server.log +__pycache__/ +*.pyc diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..eecde6ca --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,57 @@ +# 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 Initialization +- [x] Create a `requirements.txt` file with dependencies: `Flask`, `SQLAlchemy`, `Flask-SQLAlchemy`, `Flask-Migrate`, `python-dotenv`. +- [x] Create a `config.py` file to handle environment configurations (Secret key, Database URL). +- [x] Create the application package structure: `app/` folder and `app/__init__.py`. +- [x] Implement the Flask "Application Factory" pattern in `app/__init__.py`. +- [x] Create a `run.py` entry point file in the root directory. +- [x] Verify the server starts (`python run.py`) and returns a "Hello World" on the root route. + +## Phase 2: Database Layer +- [x] Create `app/models.py`. +- [x] Define the `GiftIdea` model using SQLAlchemy 2.0 syntax (`Mapped`, `mapped_column`). +- [x] Define the `Vote` model with a Foreign Key to `GiftIdea`. +- [x] Define the `Comment` model with a Foreign Key to `GiftIdea`. +- [x] Initialize Flask-Migrate in the app factory. +- [x] Run `flask db init`, `flask db migrate -m "Initial migration"`, and `flask db upgrade` to create the SQLite database. + +## Phase 3: Core API & Logic (Backend) +- [x] Create `app/routes.py` and define a Blueprint for the main application. +- [x] Register the Blueprint in `app/__init__.py`. +- [x] Implement `POST /api/gifts` endpoint to accept JSON data and save new gifts. +- [x] Implement `GET /api/gifts` endpoint with query parameters for filtering (`category`) and sorting (`nice_list`, `popularity`, `newest`). +- [x] Implement `POST /api/gifts//vote` endpoint to save scores. +- [x] Implement `POST /api/gifts//comments` and `GET /api/gifts//comments` endpoints. + +## Phase 4: Frontend Base & Assets +- [x] Create `app/templates/base.html`. +- [x] Add Tailwind CSS via CDN to `base.html` head. +- [x] Configure the Tailwind theme in `base.html` (script tag) to include custom colors: `santa-red` (red-600), `pine-green` (green-700), `snow-white` (slate-50). +- [x] Build the layout in `base.html`: Festive Header (Navigation), Main Content Block, Footer. +- [x] Add a "Hero" section to the header with a placeholder Santa/Sleigh image. + +## Phase 5: Frontend Feature Implementation +- [x] Create `app/templates/index.html` extending `base.html`. +- [x] Implement the "Submit Gift" form in a Modal or separate section on `index.html`. +- [x] Create a Jinja2 macro or component for the "Gift Card" to display Gift Name, Description, Category Badge, and Score. +- [x] Render the list of `GiftIdea` items on `index.html` (server-side initial render). + +## Phase 6: Interactivity (JavaScript) +- [x] Create `app/static/js/main.js` and link it in `base.html`. +- [x] Implement AJAX fetch logic to handle the "Submit Gift" form without reloading. +- [x] Add event listeners to the "Vote" buttons (Snowflakes) to call `POST /api/gifts//vote` and update the UI score dynamically. +- [x] Add "Show Comments" toggle on Gift Cards to fetch and display comments asynchronously. +- [x] Implement the "Post Comment" logic via AJAX. + +## Phase 7: Completion & Version Control +- [ ] Verify all functionalities: Submission, Voting, Commenting, Filtering/Sorting. +- [ ] Create a `README.md` file explaining the application functions, architecture, and how to run 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 new file mode 100644 index 00000000..2ff001cc --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# North Pole Wishlist 🎅 + +Welcome to the North Pole Wishlist, a festive community platform where users can share, discover, and vote on the best holiday gift ideas! + +## Features + +- **Gift Submission**: Share your unique gift ideas with the community. +- **Categorization**: Organize gifts by categories like Kids, Parents, Tech, DIY, etc. +- **Voting ("Naughty or Nice")**: Upvote your favorite gifts to help them reach the top of the "Nice List". +- **Comments**: Discuss gift ideas, ask for purchase links, or share reviews. +- **Festive UI**: A fully themed "Santa's Workshop" interface using Tailwind CSS. + +## Tech Stack + +- **Backend**: Python, Flask, SQLAlchemy 2.0 +- **Database**: SQLite (Development), with Flask-Migrate (Alembic) +- **Frontend**: HTML5 (Jinja2 Templates), Tailwind CSS, Vanilla JavaScript + +## Setup & Running Locally + +1. **Clone the repository**: + ```bash + git clone + cd north-pole-wishlist + ``` + +2. **Install Dependencies**: + ```bash + pip install -r requirements.txt + ``` + +3. **Initialize the Database**: + ```bash + export FLASK_APP=run.py + flask db upgrade + ``` + +4. **Run the Application**: + ```bash + python run.py + ``` + +5. **Visit**: Open [http://127.0.0.1:5000](http://127.0.0.1:5000) in your browser. + +## API Endpoints + +- `GET /api/gifts`: List all gifts (supports `category` and `sort_by` params). +- `POST /api/gifts`: Submit a new gift. +- `POST /api/gifts//vote`: Vote for a gift. +- `POST /api/gifts//comments`: Add a comment. +- `GET /api/gifts//comments`: Get comments for a gift. + +## Project Structure + +- `app/`: Application source code. + - `models.py`: Database models. + - `routes.py`: API endpoints and views. + - `templates/`: HTML templates. + - `static/`: CSS/JS assets. +- `migrations/`: Database migration scripts. +- `config.py`: Configuration settings. +- `run.py`: Entry point. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..d34caed8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from config import Config + +db = SQLAlchemy() +migrate = Migrate() + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + migrate.init_app(app, db) + + from app import models # Register models + + from app import routes + app.register_blueprint(routes.bp) + + return app diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..5a082268 --- /dev/null +++ b/app/models.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import List, Optional +import sqlalchemy as sa +import sqlalchemy.orm as so +from app import db + +class GiftIdea(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + name: 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=datetime.utcnow) + + votes: so.Mapped[List['Vote']] = so.relationship(back_populates='gift') + comments: so.Mapped[List['Comment']] = so.relationship(back_populates='gift') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'category': self.category, + 'created_at': self.created_at.isoformat(), + 'average_score': self.average_score, + 'vote_count': len(self.votes), + 'comment_count': len(self.comments) + } + + @property + def average_score(self): + if not self.votes: + return 0 + return sum(v.score for v in self.votes) / len(self.votes) + +class Vote(db.Model): + 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(sa.Integer) + created_at: so.Mapped[datetime] = so.mapped_column(default=datetime.utcnow) + + gift: so.Mapped['GiftIdea'] = so.relationship(back_populates='votes') + +class Comment(db.Model): + 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(50), default="Secret Santa") + content: so.Mapped[str] = so.mapped_column(sa.String(500)) + created_at: so.Mapped[datetime] = so.mapped_column(default=datetime.utcnow) + + gift: so.Mapped['GiftIdea'] = so.relationship(back_populates='comments') + + def to_dict(self): + return { + 'id': self.id, + 'gift_id': self.gift_id, + 'author_name': self.author_name, + 'content': self.content, + 'created_at': self.created_at.isoformat() + } diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 00000000..d7fc0559 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,127 @@ +from flask import Blueprint, jsonify, request, render_template +from app import db +from app.models import GiftIdea, Vote, Comment +import sqlalchemy as sa +from sqlalchemy import func + +bp = Blueprint('main', __name__) + +def _get_filtered_gifts(category=None, sort_by='newest'): + query = sa.select(GiftIdea) + + if category: + query = query.where(GiftIdea.category == category) + + # Sorting + if sort_by == 'nice_list': + # AVG score desc, Count desc + subq = ( + sa.select( + Vote.gift_id, + func.avg(Vote.score).label('avg_score'), + func.count(Vote.id).label('vote_count') + ) + .group_by(Vote.gift_id) + .subquery() + ) + query = query.outerjoin(subq, GiftIdea.id == subq.c.gift_id) + query = query.order_by(subq.c.avg_score.desc().nulls_last(), subq.c.vote_count.desc()) + + elif sort_by == 'popularity': + # Vote count desc + subq = ( + sa.select( + Vote.gift_id, + func.count(Vote.id).label('vote_count') + ) + .group_by(Vote.gift_id) + .subquery() + ) + query = query.outerjoin(subq, GiftIdea.id == subq.c.gift_id) + query = query.order_by(subq.c.vote_count.desc().nulls_last()) + + else: # newest + query = query.order_by(GiftIdea.created_at.desc()) + + return db.session.scalars(query).all() + +@bp.route('/') +def index(): + category = request.args.get('category') + sort_by = request.args.get('sort_by', 'newest') + gifts = _get_filtered_gifts(category, sort_by) + return render_template('index.html', gifts=gifts) + +@bp.route('/api/gifts', methods=['GET']) +def get_gifts(): + category = request.args.get('category') + sort_by = request.args.get('sort_by', 'newest') + gifts = _get_filtered_gifts(category, sort_by) + return jsonify([g.to_dict() for g in gifts]) + +@bp.route('/api/gifts', methods=['POST']) +def create_gift(): + data = request.get_json() + if not data or not data.get('name') or not data.get('description') or not data.get('category'): + return jsonify({'error': 'Missing required fields'}), 400 + + # Validation logic could go here (length checks) + if len(data['name']) > 100: + return jsonify({'error': 'Name too long'}), 400 + + gift = GiftIdea( + name=data['name'], + description=data['description'], + category=data['category'] + ) + db.session.add(gift) + db.session.commit() + return jsonify(gift.to_dict()), 201 + +@bp.route('/api/gifts//vote', methods=['POST']) +def vote_gift(id): + data = request.get_json() + score = data.get('score') + if not score or not isinstance(score, int) or not (1 <= score <= 5): + return jsonify({'error': 'Invalid score (1-5)'}), 400 + + gift = db.session.get(GiftIdea, id) + if not gift: + return jsonify({'error': 'Gift not found'}), 404 + + vote = Vote(gift_id=id, score=score) + db.session.add(vote) + db.session.commit() + + return jsonify({ + 'message': 'Vote recorded', + 'new_average': gift.average_score, + 'total_votes': len(gift.votes) + }) + +@bp.route('/api/gifts//comments', methods=['GET']) +def get_comments(id): + gift = db.session.get(GiftIdea, id) + if not gift: + return jsonify({'error': 'Gift not found'}), 404 + + return jsonify([c.to_dict() for c in gift.comments]) + +@bp.route('/api/gifts//comments', methods=['POST']) +def create_comment(id): + data = request.get_json() + content = data.get('content') + author_name = data.get('author_name', 'Secret Santa') + + if not content or len(content) < 10: + return jsonify({'error': 'Content must be at least 10 chars'}), 400 + + gift = db.session.get(GiftIdea, id) + if not gift: + return jsonify({'error': 'Gift not found'}), 404 + + comment = Comment(gift_id=id, content=content, author_name=author_name) + db.session.add(comment) + db.session.commit() + + return jsonify(comment.to_dict()), 201 diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 00000000..667ad192 --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,131 @@ +// Submit Gift +document.getElementById('giftForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData.entries()); + + try { + const res = await fetch('/api/gifts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (res.ok) { + window.location.reload(); // Simple reload to show new gift + } else { + const err = await res.json(); + alert('Error: ' + err.error); + } + } catch (error) { + console.error('Error submitting gift:', error); + } +}); + +// Vote +document.querySelectorAll('.vote-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const id = btn.dataset.id; + const score = 5; // Default simple upvote (5 snowflakes) for this iteration, or could prompt user + + // Let's implement a simple "Like" behavior which sends a 5 + // Ideally, we'd have a star rater, but spec said "Vote (Upvote/Downvote) OR Score 1-5" + // We'll stick to a simple 5 star bump for "Love this!" + + try { + const res = await fetch(`/api/gifts/${id}/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ score: 5 }) + }); + + if (res.ok) { + const data = await res.json(); + document.getElementById(`score-${id}`).textContent = data.new_average.toFixed(1); + // Also update count text if needed, but it's inside the same span in my HTML? + // Wait, HTML structure was: {{ score }} ({{ count }}) + // I need to target the count too. + // Let's just update the score for now, user sees immediate feedback. + // Ideally I should put IDs on both spans. + // Let's stick to simple "flash" or alert or just the score update. + + // Animation effect + btn.innerHTML = '❤️ Voted!'; + setTimeout(() => btn.innerHTML = '❤️ Vote', 2000); + } + } catch (error) { + console.error('Error voting:', error); + } + }); +}); + +// Toggle Comments +async function toggleComments(giftId) { + const container = document.getElementById(`comments-${giftId}`); + const list = document.getElementById(`comments-list-${giftId}`); + + if (!container.classList.contains('hidden')) { + container.classList.add('hidden'); + return; + } + + container.classList.remove('hidden'); + list.innerHTML = '

Loading...

'; + + try { + const res = await fetch(`/api/gifts/${giftId}/comments`); + const comments = await res.json(); + + if (comments.length === 0) { + list.innerHTML = '

No comments yet. Be the first!

'; + } else { + list.innerHTML = comments.map(c => ` +
+
${c.author_name}
+

${c.content}

+
+ `).join(''); + } + } catch (error) { + list.innerHTML = '

Failed to load comments.

'; + } +} + +// Post Comment +async function postComment(event, giftId) { + event.preventDefault(); + const form = event.target; + const input = form.querySelector('input[name="content"]'); + const content = input.value; + + try { + const res = await fetch(`/api/gifts/${giftId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content }) // Default author is Secret Santa + }); + + if (res.ok) { + const comment = await res.json(); + const list = document.getElementById(`comments-list-${giftId}`); + + // Remove "No comments" msg if exists + if (list.querySelector('p')?.textContent.includes('No comments')) { + list.innerHTML = ''; + } + + const div = document.createElement('div'); + div.className = 'bg-white p-3 rounded-lg border border-gray-100 text-sm'; + div.innerHTML = ` +
${comment.author_name}
+

${comment.content}

+ `; + list.appendChild(div); + input.value = ''; // Clear input + } else { + alert('Error posting comment'); + } + } catch (error) { + console.error('Error posting comment:', error); + } +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 00000000..fd2c2bf6 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,84 @@ + + + + + + {% block title %}North Pole Wishlist{% endblock %} + + + + + + + +
+ + + + {% block hero %} +
+ +
+
+

+ Santa's Workshop Gift Exchange +

+

+ Share your favorite holiday gift ideas and vote for the best presents of the season! +

+ +
+
+ {% endblock %} +
+ + +
+ {% block content %} + {% endblock %} +
+ + +
+
+

© 2025 North Pole Engineering. Made with ❄️ and 🍪.

+
+
+ + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 00000000..d6edd566 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} + +{% block content %} + + +
+ + +
+ Sort by: + +
+
+ + +
+ {% for gift in gifts %} + {{ gift_card(gift) }} + {% else %} +
+

No gifts found on the sleigh yet!

+
+ {% endfor %} +
+ + +{% macro gift_card(gift) %} +
+
+
+ + {{ gift.category }} + +
+ ❄️ + {{ "%.1f"|format(gift.average_score) }} + ({{ gift.votes|length }}) +
+
+

{{ gift.name }}

+

{{ gift.description }}

+
+ +
+ + +
+ + + +
+{% endmacro %} + + + + + +{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 00000000..97d0fdfc --- /dev/null +++ b/config.py @@ -0,0 +1,9 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess-santa-secret' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'app.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False 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/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/881c18fb91d2_initial_migration.py b/migrations/versions/881c18fb91d2_initial_migration.py new file mode 100644 index 00000000..b2d2ccb1 --- /dev/null +++ b/migrations/versions/881c18fb91d2_initial_migration.py @@ -0,0 +1,54 @@ +"""Initial migration + +Revision ID: 881c18fb91d2 +Revises: +Create Date: 2025-12-09 09:25:46.416505 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '881c18fb91d2' +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('name', 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=50), 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..1d463277 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask +SQLAlchemy +Flask-SQLAlchemy +Flask-Migrate +python-dotenv diff --git a/run.py b/run.py new file mode 100644 index 00000000..a3fdaf3c --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run(debug=True) From d017a49b367afc0ed428a73f37bbde8648f999a9 Mon Sep 17 00:00:00 2001 From: ggalloro Date: Tue, 9 Dec 2025 09:36:09 +0100 Subject: [PATCH 2/3] Fix Jinja2 UndefinedError by moving macro to components.html --- app/templates/components.html | 41 ++++++++++++++++++++++++++++++ app/templates/index.html | 47 ++--------------------------------- 2 files changed, 43 insertions(+), 45 deletions(-) create mode 100644 app/templates/components.html diff --git a/app/templates/components.html b/app/templates/components.html new file mode 100644 index 00000000..a42283ab --- /dev/null +++ b/app/templates/components.html @@ -0,0 +1,41 @@ +{% macro gift_card(gift) %} +
+
+
+ + {{ gift.category }} + +
+ ❄️ + {{ "%.1f"|format(gift.average_score) }} + ({{ gift.votes|length }}) +
+
+

{{ gift.name }}

+

{{ gift.description }}

+
+ +
+ + +
+ + + +
+{% endmacro %} diff --git a/app/templates/index.html b/app/templates/index.html index d6edd566..266e02fb 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components.html" import gift_card %} {% block content %} @@ -33,50 +34,6 @@ {% endfor %} - -{% macro gift_card(gift) %} -
-
-
- - {{ gift.category }} - -
- ❄️ - {{ "%.1f"|format(gift.average_score) }} - ({{ gift.votes|length }}) -
-
-

{{ gift.name }}

-

{{ gift.description }}

-
- -
- - -
- - - -
-{% endmacro %} - - -{% endblock %} +{% endblock %} \ No newline at end of file From 80776549885ce12d20e730d5bfce84fe611b5b68 Mon Sep 17 00:00:00 2001 From: ggalloro Date: Tue, 9 Dec 2025 09:40:28 +0100 Subject: [PATCH 3/3] Allow users to vote with score 1-5 --- app/static/js/main.js | 55 +++++++++++++++++++++-------------- app/templates/components.html | 13 ++++++--- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/app/static/js/main.js b/app/static/js/main.js index 667ad192..1192b432 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -22,41 +22,52 @@ document.getElementById('giftForm')?.addEventListener('submit', async (e) => { } }); -// Vote -document.querySelectorAll('.vote-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - const id = btn.dataset.id; - const score = 5; // Default simple upvote (5 snowflakes) for this iteration, or could prompt user - - // Let's implement a simple "Like" behavior which sends a 5 - // Ideally, we'd have a star rater, but spec said "Vote (Upvote/Downvote) OR Score 1-5" - // We'll stick to a simple 5 star bump for "Love this!" +// Vote (Event Delegation) +document.addEventListener('click', async (e) => { + if (e.target.closest('.vote-btn')) { + const btn = e.target.closest('.vote-btn'); + const container = btn.closest('.vote-actions'); + const id = container.dataset.id; + const score = parseInt(btn.dataset.score); try { const res = await fetch(`/api/gifts/${id}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ score: 5 }) + body: JSON.stringify({ score: score }) }); if (res.ok) { const data = await res.json(); - document.getElementById(`score-${id}`).textContent = data.new_average.toFixed(1); - // Also update count text if needed, but it's inside the same span in my HTML? - // Wait, HTML structure was: {{ score }} ({{ count }}) - // I need to target the count too. - // Let's just update the score for now, user sees immediate feedback. - // Ideally I should put IDs on both spans. - // Let's stick to simple "flash" or alert or just the score update. - // Animation effect - btn.innerHTML = '❤️ Voted!'; - setTimeout(() => btn.innerHTML = '❤️ Vote', 2000); + // Update score display + const scoreDisplay = document.getElementById(`score-${id}`); + if (scoreDisplay) { + scoreDisplay.textContent = data.new_average.toFixed(1); + // Update count as well (need to find the sibling span) + const countSpan = scoreDisplay.nextElementSibling; + if (countSpan) { + countSpan.textContent = `(${data.total_votes})`; + } + } + + // Visual Feedback + // Remove existing feedback if any + container.querySelectorAll('.vote-feedback').forEach(el => el.remove()); + + const feedback = document.createElement('span'); + feedback.className = 'vote-feedback text-xs text-santa-red font-bold ml-1 animate-pulse'; + feedback.innerText = 'Thanks!'; + container.appendChild(feedback); + + setTimeout(() => feedback.remove(), 2000); + } else { + alert('Error voting. Please try again.'); } } catch (error) { console.error('Error voting:', error); } - }); + } }); // Toggle Comments @@ -128,4 +139,4 @@ async function postComment(event, giftId) { } catch (error) { console.error('Error posting comment:', error); } -} +} \ No newline at end of file diff --git a/app/templates/components.html b/app/templates/components.html index a42283ab..a7f995fd 100644 --- a/app/templates/components.html +++ b/app/templates/components.html @@ -16,9 +16,14 @@

{{ gift.name }}

- +
+ Rate: + + + + + +
@@ -38,4 +43,4 @@

{{ gift.name }}

-{% endmacro %} +{% endmacro %} \ No newline at end of file