Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
app.db
server.log
__pycache__/
*.pyc
57 changes: 57 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -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/<id>/vote` endpoint to save scores.
- [x] Implement `POST /api/gifts/<id>/comments` and `GET /api/gifts/<id>/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/<id>/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.
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <repository-url>
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/<id>/vote`: Vote for a gift.
- `POST /api/gifts/<id>/comments`: Add a comment.
- `GET /api/gifts/<id>/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.
21 changes: 21 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 The `average_score` property dynamically calculates the average by loading all votes into memory every time it's accessed. This is inefficient and can lead to performance issues, especially with a high number of votes. To optimize, it would be better to use a database-level average calculation or a cached property.
    </COMMENT>

}

@property
def average_score(self):
if not self.votes:
return 0
return sum(v.score for v in self.votes) / len(self.votes)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 For data integrity, it's good practice to explicitly set `nullable=False` for foreign key columns that are required.
    ```suggestion
        gift_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('gift_idea.id'), nullable=False)
    ```
    </COMMENT>


Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 For data integrity, it's good practice to explicitly set `nullable=False` for columns that are required.
    ```suggestion
        score: so.Mapped[int] = so.mapped_column(sa.Integer, nullable=False)
    ```
    </COMMENT>

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)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 For data integrity, it's good practice to explicitly set `nullable=False` for foreign key columns that are required.
    ```suggestion
        gift_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('gift_idea.id'), nullable=False)
    ```
    </COMMENT>

gift: so.Mapped['GiftIdea'] = so.relationship(back_populates='votes')

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 For data integrity, it's good practice to explicitly set `nullable=False` for columns that are required.
    ```suggestion
        content: so.Mapped[str] = so.mapped_column(sa.String(500), nullable=False)
    ```
    </COMMENT>

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()
}
127 changes: 127 additions & 0 deletions app/routes.py
Original file line number Diff line number Diff line change
@@ -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')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 The API endpoints lack rate limiting, leaving them vulnerable to denial-of-service (DoS) attacks or abuse. Implementing a rate-limiting solution (e.g., using `Flask-Limiter`) is recommended to protect the application from excessive requests.

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/<int:id>/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/<int:id>/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/<int:id>/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
Loading