Dynamic Portfolio Website with Flask
Abstract
Section titled “Abstract”A portfolio site is the project that pays for itself — you learn Flask and end up with something to show employers. This one is smarter than a pile of hard-coded HTML: all your content (bio, skills, projects, experience) lives in a single JSON file, the app generates its own Bootstrap templates and CSS on first run, and a contact form saves messages you can read in a tiny admin inbox. You’ll learn the data-driven page pattern, Jinja template inheritance, form handling, and — importantly — spot the security mistakes the starter code makes so you don’t ship them.
You will leave understanding:
- Why separating content (JSON) from presentation (templates) beats hard-coded HTML.
- How Jinja
{% extends %}/{% block %}inheritance avoids repeating layout. - The contact-form flow: POST → validate → persist → flash → redirect.
- The concrete security holes here (
debug=True, f-string HTML, open admin) and their fixes.
Prerequisites
Section titled “Prerequisites”- Python 3.7 or above.
- A text editor or IDE.
- Flask:
pip install flask. - Comfort with Flask routing and Jinja basics (see Simple Blog with Flask).
- Basic HTML/CSS to customize the look.
flowchart TD
n0(["script start"])
n337["create_static_files()"]
n338["create_templates()"]
n2["main()"]
subgraph PortfolioData
n339["__init__()"]
n340["add_project()"]
n341["load_data()"]
n342["save_data()"]
end
n339 --> n341
n340 --> n342
n341 --> n342
n0 --> n2
n2 --> n337
n2 --> n338
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
portfolio-website. - Inside it, create
portfoliowebsite.py. - Install Flask:
pip install flask.
The app creates the templates/ and static/ folders and their files for you on first run — no manual setup.
Write the code
Section titled “Write the code”portfoliowebsite.py
pch.viewSource# Personal Portfolio Website (Flask)
import sys
import hmac
from functools import wraps
from flask import (Flask, render_template, render_template_string,
request, flash, redirect, url_for, jsonify, session)
import os
import secrets
import json
from datetime import datetime
from werkzeug.utils import secure_filename
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
app = Flask(__name__)
# Flask signs the session cookie with this. A key committed to a
# repository is a key everyone has, and forging `session['admin']` is
# then trivial -- so it comes from the environment, and the fallback is
# random per process, which logs everyone out on restart rather than
# accepting a known value.
app.secret_key = os.environ.get('PORTFOLIO_SECRET_KEY') or secrets.token_hex(32)
# Likewise the password. `ADMIN_PASSWORD` unset means the admin page is
# unreachable rather than open with a default nobody changed.
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD')
# Configuration
UPLOAD_FOLDER = 'static/uploads'
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'doc', 'docx'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# Create necessary directories
os.makedirs('templates', exist_ok=True)
os.makedirs('static/css', exist_ok=True)
os.makedirs('static/js', exist_ok=True)
os.makedirs('static/images', exist_ok=True)
os.makedirs('static/uploads', exist_ok=True)
class PortfolioData:
def __init__(self):
self.data_file = 'portfolio_data.json'
self.load_data()
def load_data(self):
"""Load portfolio data from JSON file"""
default_data = {
"personal_info": {
"name": "John Doe",
"title": "Full Stack Developer",
"email": "john.doe@example.com",
"phone": "+1 (555) 123-4567",
"location": "New York, NY",
"linkedin": "https://linkedin.com/in/johndoe",
"github": "https://github.com/johndoe",
"website": "https://johndoe.dev",
"bio": "Passionate full-stack developer with 5+ years of experience in building scalable web applications. I love creating efficient solutions and learning new technologies."
},
"skills": [
{"name": "Python", "level": 90, "category": "Backend"},
{"name": "JavaScript", "level": 85, "category": "Frontend"},
{"name": "React", "level": 80, "category": "Frontend"},
{"name": "Flask/Django", "level": 88, "category": "Backend"},
{"name": "HTML/CSS", "level": 92, "category": "Frontend"},
{"name": "SQL", "level": 85, "category": "Database"},
{"name": "Git", "level": 90, "category": "Tools"},
{"name": "Docker", "level": 75, "category": "DevOps"}
],
"projects": [
{
"id": 1,
"title": "E-commerce Platform",
"description": "A full-stack e-commerce platform built with Python Flask and React",
"technologies": ["Python", "Flask", "React", "PostgreSQL", "Redis"],
"github_url": "https://github.com/johndoe/ecommerce",
"demo_url": "https://demo-ecommerce.johndoe.dev",
"image": "project1.jpg",
"featured": True,
"date": "2023-06-15"
},
{
"id": 2,
"title": "Weather Dashboard",
"description": "Real-time weather dashboard with data visualization",
"technologies": ["Python", "Django", "Chart.js", "API Integration"],
"github_url": "https://github.com/johndoe/weather-dashboard",
"demo_url": "https://weather.johndoe.dev",
"image": "project2.jpg",
"featured": True,
"date": "2023-04-20"
},
{
"id": 3,
"title": "Task Management App",
"description": "Collaborative task management application with real-time updates",
"technologies": ["Python", "FastAPI", "Vue.js", "WebSocket", "MongoDB"],
"github_url": "https://github.com/johndoe/task-manager",
"demo_url": "https://tasks.johndoe.dev",
"image": "project3.jpg",
"featured": False,
"date": "2023-02-10"
}
],
"experience": [
{
"position": "Senior Full Stack Developer",
"company": "Tech Solutions Inc.",
"location": "New York, NY",
"start_date": "2022-01-15",
"end_date": None,
"current": True,
"description": "Led development of scalable web applications serving 100K+ users. Mentored junior developers and implemented DevOps best practices."
},
{
"position": "Full Stack Developer",
"company": "StartupXYZ",
"location": "San Francisco, CA",
"start_date": "2020-06-01",
"end_date": "2021-12-31",
"current": False,
"description": "Developed and maintained multiple web applications using Python, JavaScript, and cloud technologies."
}
],
"education": [
{
"degree": "Bachelor of Science in Computer Science",
"institution": "University of Technology",
"location": "New York, NY",
"graduation_date": "2020-05-15",
"gpa": "3.8/4.0"
}
],
"certifications": [
"AWS Certified Developer - Associate",
"Google Cloud Professional Developer",
"MongoDB Certified Developer"
],
"testimonials": [
{
"name": "Sarah Johnson",
"position": "Project Manager at Tech Solutions Inc.",
"content": "John is an exceptional developer who consistently delivers high-quality work. His attention to detail and problem-solving skills are outstanding.",
"image": "testimonial1.jpg"
},
{
"name": "Mike Chen",
"position": "CTO at StartupXYZ",
"content": "Working with John was a pleasure. He's reliable, creative, and always goes the extra mile to ensure project success.",
"image": "testimonial2.jpg"
}
]
}
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r') as f:
self.data = json.load(f)
except Exception as e:
print(f"Error loading data: {e}")
self.data = default_data
else:
self.data = default_data
self.save_data()
def save_data(self):
"""Save portfolio data to JSON file"""
try:
with open(self.data_file, 'w') as f:
json.dump(self.data, f, indent=2)
except Exception as e:
print(f"Error saving data: {e}")
def add_project(self, project_data):
"""Add a new project"""
new_id = max([p['id'] for p in self.data['projects']], default=0) + 1
project_data['id'] = new_id
project_data['date'] = datetime.now().strftime('%Y-%m-%d')
self.data['projects'].append(project_data)
self.save_data()
return new_id
# Initialize portfolio data
portfolio_data = PortfolioData()
def create_templates():
"""Create HTML templates"""
# Base template
base_template = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ personal_info.name }} - {{ personal_info.title }}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ url_for('index') }}">{{ personal_info.name }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="#home">Home</a></li>
<li class="nav-item"><a class="nav-link" href="#about">About</a></li>
<li class="nav-item"><a class="nav-link" href="#skills">Skills</a></li>
<li class="nav-item"><a class="nav-link" href="#projects">Projects</a></li>
<li class="nav-item"><a class="nav-link" href="#experience">Experience</a></li>
<li class="nav-item"><a class="nav-link" href="#contact">Contact</a></li>
</ul>
</div>
</div>
</nav>
{% block content %}{% endblock %}
<!-- Footer -->
<footer class="bg-dark text-light py-4">
<div class="container text-center">
<div class="row">
<div class="col-md-6">
<p>© 2023 {{ personal_info.name }}. All rights reserved.</p>
</div>
<div class="col-md-6">
<div class="social-links">
<a href="{{ personal_info.linkedin }}" target="_blank" class="text-light me-3">
<i class="fab fa-linkedin fa-lg"></i>
</a>
<a href="{{ personal_info.github }}" target="_blank" class="text-light me-3">
<i class="fab fa-github fa-lg"></i>
</a>
<a href="mailto:{{ personal_info.email }}" class="text-light">
<i class="fas fa-envelope fa-lg"></i>
</a>
</div>
</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>'''
# Index template
index_template = '''{% extends "base.html" %}
{% block content %}
<!-- Hero Section -->
<section id="home" class="hero-section">
<div class="container">
<div class="row align-items-center min-vh-100">
<div class="col-lg-6">
<h1 class="display-4 fw-bold mb-3">Hi, I'm {{ personal_info.name }}</h1>
<h2 class="h3 text-primary mb-4">{{ personal_info.title }}</h2>
<p class="lead mb-4">{{ personal_info.bio }}</p>
<div class="hero-buttons">
<a href="#projects" class="btn btn-primary btn-lg me-3">View My Work</a>
<a href="#contact" class="btn btn-outline-primary btn-lg">Get In Touch</a>
</div>
</div>
<div class="col-lg-6 text-center">
<div class="hero-image">
<img src="{{ url_for('static', filename='images/profile.jpg') }}"
alt="{{ personal_info.name }}" class="img-fluid rounded-circle profile-img">
</div>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section id="about" class="py-5 bg-light">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto text-center">
<h2 class="section-title">About Me</h2>
<p class="lead">{{ personal_info.bio }}</p>
<div class="row mt-4">
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-envelope text-primary"></i>
<span>{{ personal_info.email }}</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-phone text-primary"></i>
<span>{{ personal_info.phone }}</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-map-marker-alt text-primary"></i>
<span>{{ personal_info.location }}</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-globe text-primary"></i>
<span>{{ personal_info.website }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Skills Section -->
<section id="skills" class="py-5">
<div class="container">
<h2 class="section-title text-center">Skills & Technologies</h2>
<div class="row">
{% for skill in skills %}
<div class="col-lg-6 mb-3">
<div class="skill-item">
<div class="d-flex justify-content-between mb-1">
<span class="fw-semibold">{{ skill.name }}</span>
<span class="text-muted">{{ skill.level }}%</span>
</div>
<div class="progress">
<div class="progress-bar" role="progressbar"
style="width: {{ skill.level }}%"
aria-valuenow="{{ skill.level }}"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
<!-- Projects Section -->
<section id="projects" class="py-5 bg-light">
<div class="container">
<h2 class="section-title text-center">Featured Projects</h2>
<div class="row">
{% for project in projects %}
{% if project.featured %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card project-card h-100">
<img src="{{ url_for('static', filename='images/' + project.image) }}"
class="card-img-top" alt="{{ project.title }}">
<div class="card-body">
<h5 class="card-title">{{ project.title }}</h5>
<p class="card-text">{{ project.description }}</p>
<div class="technologies mb-3">
{% for tech in project.technologies %}
<span class="badge bg-secondary me-1">{{ tech }}</span>
{% endfor %}
</div>
<div class="project-links">
<a href="{{ project.github_url }}" target="_blank" class="btn btn-outline-primary btn-sm me-2">
<i class="fab fa-github"></i> Code
</a>
<a href="{{ project.demo_url }}" target="_blank" class="btn btn-primary btn-sm">
<i class="fas fa-external-link-alt"></i> Demo
</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="text-center mt-4">
<a href="{{ url_for('projects') }}" class="btn btn-outline-primary">View All Projects</a>
</div>
</div>
</section>
<!-- Experience Section -->
<section id="experience" class="py-5">
<div class="container">
<h2 class="section-title text-center">Experience</h2>
<div class="row">
<div class="col-lg-8 mx-auto">
{% for exp in experience %}
<div class="experience-item mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ exp.position }}</h5>
<h6 class="card-subtitle mb-2 text-primary">{{ exp.company }}</h6>
<p class="text-muted mb-2">
<i class="fas fa-calendar"></i>
{{ exp.start_date }} -
{% if exp.current %}Present{% else %}{{ exp.end_date }}{% endif %}
| <i class="fas fa-map-marker-alt"></i> {{ exp.location }}
</p>
<p class="card-text">{{ exp.description }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section id="contact" class="py-5 bg-light">
<div class="container">
<h2 class="section-title text-center">Get In Touch</h2>
<div class="row">
<div class="col-lg-8 mx-auto">
<form id="contactForm" action="{{ url_for('contact') }}" method="POST">
<div class="row">
<div class="col-md-6 mb-3">
<input type="text" class="form-control" name="name" placeholder="Your Name" required>
</div>
<div class="col-md-6 mb-3">
<input type="email" class="form-control" name="email" placeholder="Your Email" required>
</div>
</div>
<div class="mb-3">
<input type="text" class="form-control" name="subject" placeholder="Subject" required>
</div>
<div class="mb-3">
<textarea class="form-control" name="message" rows="5" placeholder="Your Message" required></textarea>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary btn-lg">Send Message</button>
</div>
</form>
</div>
</div>
</div>
</section>
{% endblock %}'''
# Projects template
projects_template = '''{% extends "base.html" %}
{% block title %}Projects - {{ personal_info.name }}{% endblock %}
{% block content %}
<section class="py-5" style="margin-top: 70px;">
<div class="container">
<h2 class="section-title text-center">All Projects</h2>
<div class="row">
{% for project in projects %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card project-card h-100">
<img src="{{ url_for('static', filename='images/' + project.image) }}"
class="card-img-top" alt="{{ project.title }}">
<div class="card-body">
<h5 class="card-title">{{ project.title }}</h5>
<p class="card-text">{{ project.description }}</p>
<div class="technologies mb-3">
{% for tech in project.technologies %}
<span class="badge bg-secondary me-1">{{ tech }}</span>
{% endfor %}
</div>
<div class="project-links">
<a href="{{ project.github_url }}" target="_blank" class="btn btn-outline-primary btn-sm me-2">
<i class="fab fa-github"></i> Code
</a>
<a href="{{ project.demo_url }}" target="_blank" class="btn btn-primary btn-sm">
<i class="fas fa-external-link-alt"></i> Demo
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}'''
# Write templates to files
with open('templates/base.html', 'w') as f:
f.write(base_template)
with open('templates/index.html', 'w') as f:
f.write(index_template)
with open('templates/projects.html', 'w') as f:
f.write(projects_template)
def create_static_files():
"""Create CSS and JavaScript files"""
# CSS
css_content = '''/* Custom Styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--dark-color: #343a40;
--light-color: #f8f9fa;
}
body {
padding-top: 70px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.section-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 3rem;
color: var(--dark-color);
}
.hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
color: white;
min-height: 100vh;
display: flex;
align-items: center;
}
.profile-img {
width: 300px;
height: 300px;
object-fit: cover;
border: 5px solid white;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.hero-buttons .btn {
padding: 12px 30px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.info-item i {
width: 30px;
margin-right: 15px;
}
.skill-item {
margin-bottom: 1.5rem;
}
.progress {
height: 8px;
border-radius: 10px;
background-color: #e9ecef;
}
.progress-bar {
border-radius: 10px;
background: linear-gradient(90deg, var(--primary-color), #0056b3);
}
.project-card {
border: none;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.project-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.project-card .card-img-top {
height: 200px;
object-fit: cover;
}
.technologies .badge {
font-size: 0.7rem;
padding: 0.4rem 0.6rem;
}
.experience-item .card {
border-left: 4px solid var(--primary-color);
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.social-links a {
transition: color 0.3s ease;
}
.social-links a:hover {
color: var(--primary-color) !important;
}
#contactForm .form-control {
border-radius: 10px;
border: 2px solid #e9ecef;
padding: 12px 15px;
font-size: 1rem;
}
#contactForm .form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Responsive design */
@media (max-width: 768px) {
.section-title {
font-size: 2rem;
}
.profile-img {
width: 200px;
height: 200px;
}
.hero-buttons .btn {
display: block;
width: 100%;
margin-bottom: 1rem;
}
}'''
# JavaScript
js_content = '''// Smooth scrolling for navigation links
document.addEventListener('DOMContentLoaded', function() {
// Smooth scrolling for anchor links
const links = document.querySelectorAll('a[href^="#"]');
links.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href');
const targetSection = document.querySelector(targetId);
if (targetSection) {
const offsetTop = targetSection.offsetTop - 70; // Account for fixed navbar
window.scrollTo({
top: offsetTop,
behavior: 'smooth'
});
}
});
});
// Update active navigation link based on scroll position
const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
function updateActiveLink() {
const scrollPos = window.scrollY + 100;
sections.forEach(section => {
const sectionTop = section.offsetTop;
const sectionHeight = section.offsetHeight;
const sectionId = section.getAttribute('id');
if (scrollPos >= sectionTop && scrollPos < sectionTop + sectionHeight) {
navLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${sectionId}`) {
link.classList.add('active');
}
});
}
});
}
window.addEventListener('scroll', updateActiveLink);
// Contact form handling
const contactForm = document.getElementById('contactForm');
if (contactForm) {
contactForm.addEventListener('submit', function(e) {
// Add loading state
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Sending...';
submitBtn.disabled = true;
// Reset button after form submission (handled by Flask)
setTimeout(() => {
submitBtn.textContent = originalText;
submitBtn.disabled = false;
}, 3000);
});
}
// Animate skill bars on scroll
const skillBars = document.querySelectorAll('.progress-bar');
const animateSkillBars = () => {
skillBars.forEach(bar => {
const barTop = bar.getBoundingClientRect().top;
const triggerPoint = window.innerHeight * 0.8;
if (barTop < triggerPoint) {
const width = bar.style.width;
bar.style.width = '0%';
setTimeout(() => {
bar.style.width = width;
bar.style.transition = 'width 1.5s ease-in-out';
}, 100);
}
});
};
window.addEventListener('scroll', animateSkillBars);
animateSkillBars(); // Initial check
});'''
# Write static files
with open('static/css/style.css', 'w') as f:
f.write(css_content)
with open('static/js/script.js', 'w') as f:
f.write(js_content)
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/')
def index():
return render_template('index.html', **portfolio_data.data)
@app.route('/projects')
def projects():
return render_template('projects.html', **portfolio_data.data)
@app.route('/contact', methods=['POST'])
def contact():
if request.method == 'POST':
name = request.form.get('name')
email = request.form.get('email')
subject = request.form.get('subject')
message = request.form.get('message')
# Here you would typically send an email
# For demo purposes, we'll just save to a file
contact_data = {
'name': name,
'email': email,
'subject': subject,
'message': message,
'timestamp': datetime.now().isoformat()
}
# Save contact message
contacts_file = 'contacts.json'
contacts = []
if os.path.exists(contacts_file):
try:
with open(contacts_file, 'r') as f:
contacts = json.load(f)
except:
contacts = []
contacts.append(contact_data)
with open(contacts_file, 'w') as f:
json.dump(contacts, f, indent=2)
flash('Thank you for your message! I\'ll get back to you soon.', 'success')
return redirect(url_for('index') + '#contact')
def login_required(view):
"""Refuse the view to anyone without an admin session.
`functools.wraps` is not decoration: without it every decorated view is
called `wrapped`, and Flask registers endpoints by function name, so the
second decorated route raises "View function mapping is overwriting an
existing endpoint".
"""
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get('admin'):
return redirect(url_for('login', next=request.path))
return view(*args, **kwargs)
return wrapped
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Sign in to the admin area."""
error = ''
if request.method == 'POST':
supplied = request.form.get('password', '')
if not ADMIN_PASSWORD:
error = 'ADMIN_PASSWORD is not set, so nobody can sign in.'
elif hmac.compare_digest(supplied, ADMIN_PASSWORD):
# Rotate the session id on login, so a session fixed before
# sign-in cannot be reused after it.
session.clear()
session['admin'] = True
return redirect(request.args.get('next') or url_for('admin'))
else:
error = 'Wrong password.'
return render_template_string(LOGIN_PAGE, error=error), (
401 if error else 200)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('index'))
LOGIN_PAGE = """<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>Sign in</title></head>
<body style="font-family: system-ui, sans-serif; max-width: 20rem; margin: 4rem auto">
<h1>Admin sign in</h1>
{% if error %}<p style="color: #b00">{{ error }}</p>{% endif %}
<form method="post">
<p><input type="password" name="password" autofocus required></p>
<p><button type="submit">Sign in</button></p>
</form>
</body></html>"""
@app.route('/admin')
@login_required
def admin():
"""Simple admin interface to view contacts"""
contacts = []
if os.path.exists('contacts.json'):
try:
with open('contacts.json', 'r') as f:
contacts = json.load(f)
except:
contacts = []
html = '''
<!DOCTYPE html>
<html>
<head>
<title>Admin - Contact Messages</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h2>Contact Messages</h2>
<div class="row">
'''
for contact in reversed(contacts):
html += f'''
<div class="col-12 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{contact['subject']}</h5>
<h6 class="card-subtitle mb-2 text-muted">From: {contact['name']} ({contact['email']})</h6>
<p class="card-text">{contact['message']}</p>
<small class="text-muted">Received: {contact['timestamp']}</small>
</div>
</div>
</div>
'''
html += '''
</div>
</div>
</body>
</html>
'''
return html
def main():
"""Main function to run the portfolio website"""
print("Setting up Personal Portfolio Website...")
# Create templates and static files
create_templates()
create_static_files()
print("Portfolio website setup complete!")
print("\nFeatures:")
print("- Responsive design with Bootstrap")
print("- Hero section with personal info")
print("- Skills section with progress bars")
print("- Projects showcase")
print("- Experience timeline")
print("- Contact form")
print("- Admin interface for viewing messages")
print(f"\nTo run the website:")
print(f"1. Make sure Flask is installed: pip install flask")
print(f"2. Run: python {__file__}")
print(f"3. Open http://localhost:5000 in your browser")
print(f"4. Admin interface: http://localhost:5000/admin")
def smoke_test():
"""Exercise every GET route once, without starting a server.
`app.test_client()` dispatches a real request through the real application
object -- no socket, no port, no waiting. A web project that cannot be
driven this way cannot be tested either, so this is worth having whether or
not anything is capturing the output.
It returns the number of routes that did **not** answer 2xx. A smoke test
that prints `500` and exits 0 is not a test; this file shipped that way,
and both content routes were failing unnoticed because the templates had
not been written yet when the check ran.
"""
print("smoke test: dispatching one request per route\n")
broken = 0
with app.test_client() as client:
rules = sorted(app.url_map.iter_rules(), key=lambda rule: str(rule))
checked = 0
for rule in rules:
if "GET" not in rule.methods or rule.arguments:
continue
response = client.get(str(rule))
body = " ".join(response.get_data(as_text=True).split())[:60]
if response.status_code >= 400:
broken += 1
print(f" GET {str(rule):26} {response.status_code} {body}")
checked += 1
print(f"\n{checked} route(s) answered, {broken} failing. "
f"Pass --serve to start the real server instead.")
return broken
if __name__ == "__main__":
# The templates live in this file as strings and are written to disk
# before anything is served OR tested. Running the smoke test first was
# the bug: Jinja looked for templates/index.html, found nothing, and
# returned 500 while the run still exited successfully.
main()
if "--serve" in sys.argv:
app.run(debug=True, host="0.0.0.0", port=5000)
else:
raise SystemExit(1 if smoke_test() else 0) Run it
Section titled “Run it”C:\Users\Your Name\portfolio-website> python portfoliowebsite.py
# Visit http://localhost:5000 (portfolio)
# http://localhost:5000/admin (contact messages)What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.5 s and prints:
Setting up Personal Portfolio Website...
Portfolio website setup complete!
Features:
- Responsive design with Bootstrap
- Hero section with personal info
- Skills section with progress bars
- Projects showcase
- Experience timeline
- Contact form
- Admin interface for viewing messages
To run the website:
1. Make sure Flask is installed: pip install flask
2. Run: python C:\Users\Zimyo\AppData\Local\Temp\pch-project-ppo5a359\portfoliowebsite.py
3. Open http://localhost:5000 in your browser
4. Admin interface: http://localhost:5000/admin
smoke test: dispatching one request per route
GET / 200 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8
...The first 20 of 26 lines are shown; the run continues past this point.
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Content lives in JSON, not in code
Section titled “1. Content lives in JSON, not in code”class PortfolioData:
def __init__(self):
self.data_file = 'portfolio_data.json'
self.load_data()PortfolioData loads everything — personal info, skills, projects, experience — from portfolio_data.json, falling back to sensible defaults on first run and saving them out. The win: you edit one JSON file to update your site, never the Python or HTML. This separation of content from presentation is the core idea behind every CMS.
2. Self-generating templates
Section titled “2. Self-generating templates”def create_templates():
base_template = '''<!DOCTYPE html> ... '''
with open('templates/base.html', 'w') as f:
f.write(base_template)Rather than ship loose files, the app writes base.html, index.html, projects.html, the CSS, and the JS at startup. Handy for a single-file demo — though in a real project you’d keep templates as actual files under version control.
3. Jinja template inheritance
Section titled “3. Jinja template inheritance”{% extends "base.html" %}
{% block content %}
<!-- hero, about, skills, projects, contact sections -->
{% endblock %}base.html defines the shared shell (nav, footer, Bootstrap links) with {% block content %} holes; each page {% extends %} it and fills the blocks. Write the layout once, reuse everywhere — change the navbar in base.html and every page updates.
4. Data drives the markup
Section titled “4. Data drives the markup”{% for skill in skills %}
<span>{{ skill.name }}</span>
<div class="progress-bar" style="width: {{ skill.level }}%"></div>
{% endfor %}The templates loop over the JSON data. Add a skill or project to the JSON and a new card/bar appears automatically — no template edits.
5. The contact form
Section titled “5. The contact form”@app.route('/contact', methods=['POST'])
def contact():
contact_data = {'name': name, 'email': email, 'subject': subject,
'message': message, 'timestamp': datetime.now().isoformat()}
contacts.append(contact_data)
with open('contacts.json', 'w') as f:
json.dump(contacts, f, indent=2)
flash('Thank you for your message!', 'success')
return redirect(url_for('index') + '#contact')Messages append to contacts.json, the user gets a flash confirmation, and the route redirects (Post/Redirect/Get) so a refresh won’t resend. Classic Flask form handling.
Fix the Security Holes (Important)
Section titled “Fix the Security Holes (Important)”The starter code is fine for localhost but unsafe to deploy as-is:
| Starter code | Problem | Fix |
|---|---|---|
app.run(debug=True, host='0.0.0.0') | Debug console = remote code execution, exposed to the network | debug=False; bind 127.0.0.1 unless you mean to expose it |
app.secret_key = 'your-secret-key-here' | Predictable key forges sessions/flash | Load a random key from an env var |
/admin has no auth | Anyone can read your contacts | Require login (Flask-Login / basic auth) |
| Admin HTML built with f-strings | {contact['message']} injected raw → stored XSS | Render via a Jinja template (auto-escapes) |
email never validated | Junk/spoofed submissions | Validate with WTForms Email() |
The XSS one is the sneakiest:
# WRONG — visitor's message injected into HTML unescaped
html += f"<p>{contact['message']}</p>"
# RIGHT — Jinja escapes it
return render_template('admin.html', contacts=contacts)Add an Admin Login
Section titled “Add an Admin Login”Gate /admin behind a password:
from functools import wraps
from flask import session, request, redirect, url_for
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get('admin'):
return redirect(url_for('login'))
return view(*args, **kwargs)
return wrapped
@app.route('/admin')
@login_required
def admin():
...The decorator is in the shipped file, and the gate was checked rather than assumed:
no session /admin -> 302 (redirected to /login)
wrong pw /login -> 401
right pw /login -> 302 (redirected to /admin)
after login /admin -> 200
after logout /admin -> 302Three details make the difference between this and a decorator that looks right:
@wraps(view)is load-bearing. Without it every decorated view is namedwrapped, and Flask registers endpoints by function name — so the second decorated route raises “View function mapping is overwriting an existing endpoint”.session.clear()before settingadminrotates the session on login, so a session id fixed before sign-in cannot be reused after it.hmac.compare_digestrather than==compares in constant time. The timing signal from a short-circuiting string comparison is small and it is not zero, and the fix costs nothing.
The password and the signing key both come from the environment. A secret key
committed to a repository is a key everyone has, and forging session['admin']
with it is a one-liner; an unset ADMIN_PASSWORD makes the admin page
unreachable rather than open with a default nobody changed.
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| Edits to JSON don’t show | Server caches in memory | Reload data per request, or restart |
TemplateNotFound | create_templates() not run | Ensure setup runs before serving |
| Images 404 | Files missing from static/images | Add images or guard with defaults |
| Contact form resubmits on refresh | No redirect after POST | Use the PRG pattern (built in) |
| Admin shows raw HTML tags from a message | f-string injection (XSS) | Render through Jinja |
| Site reachable from other machines unexpectedly | host='0.0.0.0' | Bind 127.0.0.1 for local-only |
Variations to Try
Section titled “Variations to Try”- Render the admin page via a Jinja template (fixes XSS).
- Real email — send contact messages via SMTP instead of a file.
- Blog section — reuse the Simple Blog pattern.
- Project detail pages —
/project/<id>with full write-ups. - Dark/light theme — toggle with a CSS variable + localStorage.
- Resume download — serve a PDF; track downloads.
- Visitor analytics — log page views.
- Deploy it — ship to Render/Railway/Fly with
debug=False.
Real-World Applications
Section titled “Real-World Applications”- Personal branding — the developer portfolio itself.
- Small business sites — content-managed brochure sites.
- Agency templates — one codebase, many clients via JSON.
- Learning full-stack Flask — templating, forms, static assets together.
Educational Value
Section titled “Educational Value”- Separation of concerns — content (JSON) vs. presentation (templates).
- Template inheritance — DRY layouts with Jinja blocks.
- Form handling — validation, flashing, PRG.
- Web security — debug mode, secret keys, XSS, and auth — the things tutorials skip.
Next Steps
Section titled “Next Steps”- Fix the security issues above before deploying.
- Render the admin page through Jinja; add a login.
- Wire up real email for the contact form.
- Deploy with
debug=Falseand a real secret key.
Conclusion
Section titled “Conclusion”You built a portfolio site that’s content-driven (one JSON file), DRY (template inheritance), and interactive (a working contact form + admin inbox) — and, just as valuable, you learned to spot the security mistakes (debug=True, f-string HTML, an open admin route) that turn a nice demo into a liability. Fix those and it’s genuinely deployable. Full source on GitHub. Explore more web projects on Python Central Hub.
Pitfalls
Section titled “Pitfalls”- This project shipped with two of its three routes returning 500. The
templates are written to disk by
create_templates(), whichmain()calls; the smoke test ran beforemain(), so Jinja looked fortemplates/index.html, found nothing, and raisedTemplateNotFound. The entry point now writes the templates before it either serves or tests. - The smoke test printed the failure and exited 0. It walked the routes,
showed
500next to two of them, and returned success, so nothing downstream could tell. It now returns the number of failing routes and the caller turns that into an exit code — the single change that makes it a test rather than a report. debug=Trueis not for anything public. The Werkzeug debugger executes arbitrary code from the browser on an unhandled exception. It belongs behind an explicit development flag, never a default.host="0.0.0.0"binds every interface. Combined with the line above, that is a remote code execution endpoint on the local network.- Templates written at import time are a side effect of importing. The file creates directories and writes five files as soon as it is loaded. That is convenient for a single-file demo and surprising for anything that imports it.
- Measured: 3 routes, all answering 200 in 0.6 s, with no server started and no port bound.
app.test_client()dispatches through the real application object, so a route can be exercised without a socket. A web project that cannot be driven this way cannot be tested either.- Setup order is part of correctness: templates exist before requests, or every render is a 500.
- A check whose result cannot fail is not a check. Derive the exit code from what was found.
- 5xx and 4xx are different: a deliberate 404 is a correct answer, a 500 is never one. Which counts as failure has to be decided rather than defaulted.
-
Two routes returned 500 and the run still reported success. What was the defect in the smoke test?
pch.quizShowAnswer
B — It printed the status codes but returned a fixed value, so no status code could ever affect the outcome
-
Why did the routes return 500 in the first place?
pch.quizShowAnswer
B — The templates are written to disk by main(), and the smoke test ran before main() — so Jinja had nothing to render
-
What is wrong with app.run(debug=True, host='0.0.0.0')?
pch.quizShowAnswer
B — The debugger executes arbitrary code from the browser on an exception, and binding 0.0.0.0 exposes that to the whole network
Try it yourself
Section titled “Try it yourself”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading