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
78 changes: 78 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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: Project Initialization
- [x] Set up a Python virtual environment (`python3 -m venv venv`) and activate it.
- [x] Create a `requirements.txt` file including `Flask`, `Flask-SQLAlchemy`, and any other necessary libraries.
- [x] Install the dependencies (`pip install -r requirements.txt`).
- [x] Create the basic project structure:
- `app.py` (Main application file)
- `models.py` (Database models)
- `templates/` (HTML templates)
- `static/css/` (Stylesheets)
- `static/img/` (Images)
- [x] Create a minimal `app.py` to verify the Flask setup works (Hello World).

## Phase 2: Database & Models
- [x] Configure SQLite database connection in `app.py`.
- [x] In `models.py`, define the `Gift` model using SQLAlchemy 2.0 style (Mapped, mapped_column).
- Fields: `id`, `title`, `description`, `category`, `created_at`.
- [x] In `models.py`, define the `Vote` model.
- Fields: `id`, `gift_id`, `score`, `created_at`.
- [x] In `models.py`, define the `Comment` model.
- Fields: `id`, `gift_id`, `author_name`, `content`, `created_at`.
- [x] Create a database initialization script or function to create tables (`db.create_all()`).

## Phase 3: Core Backend Logic & Routes
- [x] Implement the Home route (`GET /`):
- Query all gifts from the database.
- Implement filtering by category (via query parameter).
- Implement sorting (Ranking: avg score, Popularity: vote count, Recency: created_at).
- [x] Implement the Submit Gift route (`GET /gifts/new` & `POST /gifts/new`):
- GET: Render the submission form.
- POST: Validate input (lengths, required fields). Create new `Gift` record.
- [x] Implement the Gift Detail route (`GET /gifts/<int:gift_id>`):
- Query specific gift by ID.
- Calculate average rating and fetch related comments.
- [x] Implement the Voting route (`POST /gifts/<int:gift_id>/vote`):
- Validate score (1-5). Create `Vote` record. Redirect or return updated score.
- [x] Implement the Commenting route (`POST /gifts/<int:gift_id>/comment`):
- Validate content length. Default author name if empty. Create `Comment` record.

## Phase 4: Frontend Implementation
- [x] Create `templates/base.html`:
- Include Bootstrap 5 via CDN.
- Link custom CSS (`static/css/style.css`).
- Define the layout (Navigation, Content Block, Footer).
- [x] Create `static/css/style.css`:
- Implement the "Christmas Aesthetic" (Deep Red #8B0000, Forest Green #228B22).
- Style fonts and general layout.
- [x] Create `templates/index.html` (Home):
- Extend `base.html`.
- Hero section with Santa image placeholder.
- Grid of Gift Cards showing Title, Category, Rating.
- [x] Create `templates/submit_gift.html`:
- Form with fields: Title, Category (Select), Description.
- [x] Create `templates/gift_detail.html`:
- Detailed view of the gift.
- Section for Voting (Snowflake icons).
- Section for Comments list and "Add Comment" form.

## Phase 5: Refinement & Assets
- [x] Add the "Santa flying on sleigh" hero image to `static/img/` (or use a placeholder/generated one).
- [x] Add the "Snowflake" icon for ratings (SVG or image).
- [x] Ensure all forms have proper HTML5 validation attributes (`required`, `maxlength`).
- [x] Test all routes and flows manually to ensure stability.

## Phase 6: Completion & Version Control
- [ ] Verify application functionality (Submit a gift, vote on it, comment on it, check filters).
- [ ] Create a `README.md` file:
- Explain features (Gift submission, Voting, Comments).
- Instructions to run locally (`python app.py`).
- Tech stack details.
- [ ] 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.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# North Pole Wishlist

The **North Pole Wishlist** is a community-driven platform designed to help users discover, share, and curate the best gift ideas for the holiday season. It features a festive Christmas theme and allows elves (users) to submit ideas, vote on them ("Naughty or Nice"), and discuss the best presents.

## Features

- **Browse Gifts**: View a curated list of gift ideas, filterable by category (Kids, Parents, Tech, etc.) and sortable by popularity or rating.
- **Submit Ideas**: Share your own gift suggestions with the community.
- **Naughty or Nice Voting**: Rate gifts on a scale of 1 to 5 Snowflakes.
- **Elf Discussion**: Leave comments on gift ideas to share reviews or ask questions.
- **Festive UI**: A fully themed interface with Santa, snowflakes, and holiday colors.

## Tech Stack

- **Backend**: Python 3, Flask
- **Database**: SQLite (Development), SQLAlchemy 2.0 ORM
- **Frontend**: HTML5, Jinja2 Templates, Bootstrap 5, Custom CSS
- **Icons**: FontAwesome

## Local Development Setup

1. **Clone the repository**:
```bash
git clone <repository-url>
cd north-pole-wishlist
```

2. **Create and activate a virtual environment**:
```bash
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```

3. **Install dependencies**:
```bash
pip install -r requirements.txt
```

4. **Run the application**:
```bash
python app.py
```

5. **Open in Browser**:
Visit `http://127.0.0.1:5000` to start exploring the wishlist!

## Project Structure

- `app.py`: Main Flask application entry point and route definitions.
- `models.py`: SQLAlchemy database models (Gift, Vote, Comment).
- `templates/`: Jinja2 HTML templates.
- `static/`: CSS styles and images.
- `instance/`: Contains the SQLite database (`northpole.db`).

## License

Copyright 2025 North Pole Workshop. Made with ❤️ and ❄️.
Binary file added __pycache__/app.cpython-313.pyc
Binary file not shown.
Binary file added __pycache__/models.cpython-313.pyc
Binary file not shown.
129 changes: 129 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import os
from flask import Flask, render_template, request, redirect, url_for, abort
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import select, func, desc

class Base(DeclarativeBase):
pass

db = SQLAlchemy(model_class=Base)

def create_app():
app = Flask(__name__)
Copy link

Choose a reason for hiding this comment

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

🟡 Using a hardcoded default for the SECRET_KEY is a security risk, as it could be used in a production environment if the SECRET_KEY environment variable is not set. For production, the key should always be set as an environment variable and the application should fail to start if it's missing. For development, you can generate a random key.

Suggested change
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or __import__('secrets').token_hex(16)

app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///northpole.db')
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key')
Copy link

Choose a reason for hiding this comment

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

  • Vulnerability: Hardcoded Secret
  • Severity: Medium
  • Description: A hardcoded secret key is used as a default value. In a production environment, this key could be easily compromised, leading to session hijacking and other attacks.
  • Recommendation: Use a randomly generated secret key and load it from an environment variable. Do not provide a default value in the code.


db.init_app(app)

with app.app_context():
import models
db.create_all()

from models import Gift, Vote, Comment

@app.route('/')
def index():
category = request.args.get('category')
sort_by = request.args.get('sort', 'newest')

stmt = select(Gift)

if category:
stmt = stmt.where(Gift.category == category)

if sort_by == 'popular':
# Sort by vote count
stmt = stmt.outerjoin(Gift.votes).group_by(Gift.id).order_by(func.count(Vote.id).desc())
elif sort_by == 'ranking':
# Sort by average score
stmt = stmt.outerjoin(Gift.votes).group_by(Gift.id).order_by(func.avg(Vote.score).desc())
else: # newest
stmt = stmt.order_by(Gift.created_at.desc())

gifts = db.session.scalars(stmt).all()

# Calculate average scores for display (could be optimized)
gift_stats = {}
for gift in gifts:
avg_score = db.session.scalar(select(func.avg(Vote.score)).where(Vote.gift_id == gift.id))
vote_count = db.session.scalar(select(func.count(Vote.id)).where(Vote.gift_id == gift.id))
gift_stats[gift.id] = {
'avg_score': round(avg_score, 1) if avg_score else 0,
'vote_count': vote_count
}

Comment on lines +48 to +55
Copy link

Choose a reason for hiding this comment

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

🟠 The current implementation for fetching gift statistics causes an N+1 query problem. It executes two additional database queries for every gift in the list. This can be optimized by computing the statistics for all gifts in a single, more efficient query.

Suggested change
for gift in gifts:
avg_score = db.session.scalar(select(func.avg(Vote.score)).where(Vote.gift_id == gift.id))
vote_count = db.session.scalar(select(func.count(Vote.id)).where(Vote.gift_id == gift.id))
gift_stats[gift.id] = {
'avg_score': round(avg_score, 1) if avg_score else 0,
'vote_count': vote_count
}
# Efficiently calculate stats for all displayed gifts
gift_stats = {g.id: {'avg_score': 0, 'vote_count': 0} for g in gifts}
if gifts:
gift_ids = [g.id for g in gifts]
stats_query = (
select(
Vote.gift_id,
func.count(Vote.id).label("vote_count"),
func.avg(Vote.score).label("avg_score"),
)
.where(Vote.gift_id.in_(gift_ids))
.group_by(Vote.gift_id)
)
for row in db.session.execute(stats_query):
stats = gift_stats[row.gift_id]
stats['vote_count'] = row.vote_count
stats['avg_score'] = round(row.avg_score, 1) if row.avg_score else 0

return render_template('index.html', gifts=gifts, stats=gift_stats, current_category=category, current_sort=sort_by)

@app.route('/gifts/new', methods=['GET', 'POST'])
Copy link

Choose a reason for hiding this comment

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

  • Vulnerability: Missing CSRF Protection
  • Severity: High
  • Description: The application does not implement CSRF protection. This allows attackers to trick users into performing unintended actions.
  • Recommendation: Implement CSRF protection using a library like Flask-WTF or by generating and validating CSRF tokens manually.

def submit_gift():
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
category = request.form.get('category')

if not title or len(title) > 100:
# Basic validation, ideally flash error
return "Invalid Title", 400
if not description or len(description) > 500:
return "Invalid Description", 400
if not category:
return "Category required", 400

new_gift = Gift(title=title, description=description, category=category)
db.session.add(new_gift)
db.session.commit()

return redirect(url_for('index'))

return render_template('submit_gift.html')

@app.route('/gifts/<int:gift_id>')
def gift_detail(gift_id):
gift = db.get_or_404(Gift, gift_id)

avg_score = db.session.scalar(select(func.avg(Vote.score)).where(Vote.gift_id == gift_id))
vote_count = db.session.scalar(select(func.count(Vote.id)).where(Vote.gift_id == gift_id))

Comment on lines +86 to +87
Copy link

Choose a reason for hiding this comment

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

🟡 The queries for avg_score and vote_count can be combined into a single database call for better performance.

Suggested change
vote_count = db.session.scalar(select(func.count(Vote.id)).where(Vote.gift_id == gift_id))
stats_query = select(
func.avg(Vote.score).label('avg_score'),
func.count(Vote.id).label('vote_count')
).where(Vote.gift_id == gift_id)
stats_result = db.session.execute(stats_query).one_or_none()
avg_score = stats_result.avg_score if stats_result and stats_result.avg_score else 0
vote_count = stats_result.vote_count if stats_result else 0

# Get comments ordered by newest
comments = db.session.scalars(select(Comment).where(Comment.gift_id == gift_id).order_by(Comment.created_at.desc())).all()

stats = {
'avg_score': round(avg_score, 1) if avg_score else 0,
'vote_count': vote_count
}

return render_template('gift_detail.html', gift=gift, stats=stats, comments=comments)

@app.route('/gifts/<int:gift_id>/vote', methods=['POST'])
def vote_gift(gift_id):
score = request.form.get('score', type=int)
if not score or score < 1 or score > 5:
return "Invalid Score", 400

vote = Vote(gift_id=gift_id, score=score)
db.session.add(vote)
db.session.commit()

return redirect(url_for('gift_detail', gift_id=gift_id))

@app.route('/gifts/<int:gift_id>/comment', methods=['POST'])
def comment_gift(gift_id):
author = request.form.get('author_name') or "Secret Santa"
content = request.form.get('content')

if not content or len(content) < 10 or len(content) > 500:
return "Content length must be between 10 and 500 characters", 400

comment = Comment(gift_id=gift_id, author_name=author, content=content)
db.session.add(comment)
db.session.commit()

return redirect(url_for('gift_detail', gift_id=gift_id))

return app

app = create_app()

if __name__ == '__main__':
app.run(debug=True)
Binary file added instance/northpole.db
Binary file not shown.
37 changes: 37 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy import String, Integer, ForeignKey, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app import db
Copy link

Choose a reason for hiding this comment

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

🟢 The from app import db line creates a circular dependency between app.py and models.py. While this pattern works for small Flask apps, it can become problematic as the application grows.

A more scalable approach is to initialize the db object in a separate file (e.g., database.py or extensions.py) and import it into both app.py and your models file. This decouples your components and improves maintainability.

Example (extensions.py):

from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass

db = SQLAlchemy(model_class=Base)

You would then import db from extensions in both app.py and models.py, breaking the cycle. This is a suggestion for future refactoring and does not require an immediate change.


class Gift(db.Model):
__tablename__ = 'gift_idea'

id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str] = mapped_column(String(500), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

votes: Mapped[list["Vote"]] = relationship(back_populates="gift")
comments: Mapped[list["Comment"]] = relationship(back_populates="gift")

class Vote(db.Model):
__tablename__ = 'vote'

id: Mapped[int] = mapped_column(primary_key=True)
gift_id: Mapped[int] = mapped_column(ForeignKey('gift_idea.id'), nullable=False)
score: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

gift: Mapped["Gift"] = relationship(back_populates="votes")

class Comment(db.Model):
__tablename__ = 'comment'

id: Mapped[int] = mapped_column(primary_key=True)
gift_id: Mapped[int] = mapped_column(ForeignKey('gift_idea.id'), nullable=False)
author_name: Mapped[str] = mapped_column(String(50), nullable=False, default="Secret Santa")
content: Mapped[str] = mapped_column(String(500), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

gift: Mapped["Gift"] = relationship(back_populates="comments")
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Flask
Flask-SQLAlchemy
SQLAlchemy
88 changes: 88 additions & 0 deletions static/css/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
:root {
--christmas-red: #8B0000;
--christmas-green: #228B22;
--snow-white: #F8F9FA;
--gold: #FFD700;
}

body {
background-color: var(--snow-white);
display: flex;
flex-direction: column;
min-height: 100vh;
}

.bg-christmas-red {
background-color: var(--christmas-red) !important;
}

.bg-christmas-green {
background-color: var(--christmas-green) !important;
}

.text-christmas-red {
color: var(--christmas-red) !important;
}

.text-christmas-green {
color: var(--christmas-green) !important;
}

.btn-christmas-red {
background-color: var(--christmas-red);
border-color: var(--christmas-red);
}

.btn-christmas-red:hover {
background-color: #660000;
border-color: #660000;
color: white;
}

.font-christmas {
font-family: 'Mountains of Christmas', cursive;
font-weight: 700;
}

.card {
border: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transition: transform 0.2s;
}

.card:hover {
transform: translateY(-5px);
}

.snowflake-rating {
color: var(--gold);
}

.hero-section {
background-color: #e9ecef;
padding: 2rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
background-size: cover;
background-position: center;
position: relative;
color: white;
text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
}

/* Fallback if image not loaded, though we'll try to use one */
.hero-overlay {
background-color: rgba(0, 0, 0, 0.4);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 0.5rem;
z-index: 1;
}

.hero-content {
position: relative;
z-index: 2;
}
Binary file added static/img/santa_hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading