diff --git a/app.py b/app.py index f38ba6c..b98e0b7 100644 --- a/app.py +++ b/app.py @@ -1,273 +1,237 @@ -from flask import Flask, request, render_template, redirect, url_for, session -import json -import numpy as np -import random -import math -from sklearn.feature_extraction.text import TfidfVectorizer -from sklearn.metrics.pairwise import cosine_similarity +import json, math, random +from flask import Flask, render_template, request, redirect, url_for, session, flash +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash +# Initialize Flask and database app = Flask(__name__) -app.secret_key = 'your_secret_key_here' # Replace with a secure key in production +app.config['SECRET_KEY'] = 'your_secret_key' # Replace with a secure secret key +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) -# Load movies from top_movies.json with UTF-8 encoding -with open('top_movies.json', 'r', encoding='utf-8') as f: - movies = json.load(f) +# -------------------- Models -------------------- +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password_hash = db.Column(db.String(150), nullable=False) + # Store the profile as a JSON string mapping movie title to rating. + profile = db.Column(db.Text, nullable=False, default='{}') -# Preprocess each movie -for i, movie in enumerate(movies): - movie['id'] = i # Unique ID - # Combine genres and tags into one feature string. - movie['features'] = ' '.join(movie.get('genres', [])) + ' ' + ' '.join(movie.get('tags', [])) - # Ensure numeric values for year and runtime: - try: - movie['year_num'] = int(movie.get('year', '0')) - except: - movie['year_num'] = 0 - try: - movie['runtime_num'] = float(movie.get('runtime')) if movie.get('runtime') else 0 - except: - movie['runtime_num'] = 0 - # Ensure vote_count is numeric. - try: - count = movie.get('vote_count', 0) - if isinstance(count, str): - count = count.replace(',', '') - if 'M' in count: - count = float(count.replace('M', '')) * 1e6 - else: - count = int(count) - movie['vote_count'] = int(count) - except: - movie['vote_count'] = 0 + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) -# Build the TF‑IDF vectorizer on movie features. -vectorizer = TfidfVectorizer(stop_words='english') -movie_features = [movie['features'] for movie in movies] -movie_vectors = vectorizer.fit_transform(movie_features) +# Create the database tables (if needed) +with app.app_context(): + db.create_all() -# Precompute overall ranges for numeric features. -years = [m['year_num'] for m in movies if m['year_num'] > 0] -runtimes = [m['runtime_num'] for m in movies if m['runtime_num'] > 0] -max_vote = max([m['vote_count'] for m in movies]) if movies else 1 +# -------------------- Movie Data & Class -------------------- +class Movie: + __slots__ = ('title', 'year', 'imdb_rating', 'runtime', 'features', 'description', 'poster') + def __init__(self, data): + self.title = data.get("title", "") + try: + self.year = int(data.get("year", 0)) + except (ValueError, TypeError): + self.year = 0 + try: + self.imdb_rating = float(data.get("imdb_rating", 0)) + except (ValueError, TypeError): + self.imdb_rating = 0.0 + try: + self.runtime = int(data.get("runtime", 0)) + except (ValueError, TypeError): + self.runtime = 0 + # For genres/tags, use a frozenset for fast membership testing. + genres = data.get("genres", []) + tags = data.get("tags", []) + self.features = frozenset(genres + tags) + self.description = data.get("description", "") + self.poster = data.get("poster", "") -min_year, max_year = (min(years), max(years)) if years else (0, 1) -min_runtime, max_runtime = (min(runtimes), max(runtimes)) if runtimes else (0, 1) -year_range = max_year - min_year if max_year != min_year else 1 -runtime_range = max_runtime - min_runtime if max_runtime != min_runtime else 1 -rating_range = 10.0 # Assuming ratings are on a 0–10 scale +# Preload movies from JSON once. +MOVIES = [] +MOVIE_INDEX = {} # mapping from title to Movie object +with open('movies.json', 'r', encoding='utf-8') as f: + data = json.load(f) + for entry in data: + movie = Movie(entry) + MOVIES.append(movie) + MOVIE_INDEX[movie.title] = movie -def get_predicted_movies(num=10): +# -------------------- Similarity Functions -------------------- +def jaccard_similarity(set1, set2): + if not set1 and not set2: + return 0 + inter = len(set1 & set2) + union = len(set1 | set2) + return inter / union if union else 0 + +def advanced_similarity(movie1, movie2, weights=None): + if weights is None: + weights = { + "genres_tags": 0.5, + "imdb": 0.2, + "year": 0.15, + "runtime": 0.15 + } + # Genres/tags similarity via Jaccard index. + sim_genres = jaccard_similarity(movie1.features, movie2.features) + # IMDb rating similarity: difference normalized over a 10-point scale. + sim_imdb = max(0, 1 - abs(movie1.imdb_rating - movie2.imdb_rating) / 10) + # Year similarity using exponential decay. + sim_year = math.exp(-abs(movie1.year - movie2.year) / 10) + # Runtime similarity using exponential decay. + sim_runtime = math.exp(-abs(movie1.runtime - movie2.runtime) / 30) + overall = (weights["genres_tags"] * sim_genres + + weights["imdb"] * sim_imdb + + weights["year"] * sim_year + + weights["runtime"] * sim_runtime) + return overall + +def recommend_movie(profile, weights=None): """ - Return up to `num` movies that haven't been shown yet. - Uses the user's past ratings to predict which unseen movies they might like. - If no ratings exist, falls back to random selection. + Given a user profile (dict mapping movie title to rating), return one recommended Movie. + The function iterates over candidate movies (those not rated yet) and computes an aggregated + weighted similarity score against all rated movies. """ - asked = session.get('asked_movies', []) - available = [m for m in movies if m['id'] not in asked] - if not available: - return [] - rated = session.get('rated_movies', {}) - # Fallback to random selection if there are no like/dislike ratings. - if not rated or not any(r in ['like', 'dislike'] for r in rated.values()): - random.shuffle(available) - return available[:num] + rated_titles = set(profile.keys()) + candidates = [m for m in MOVIES if m.title not in rated_titles] - # Build prediction profiles. - liked_ids = [int(mid) for mid, rating in rated.items() if rating == 'like'] - disliked_ids = [int(mid) for mid, rating in rated.items() if rating == 'dislike'] - - if liked_ids: - liked_profile = np.asarray(movie_vectors[liked_ids].mean(axis=0)) + best_movie = None + best_score = -1 + for candidate in candidates: + weighted_scores = [] + for title, rating in profile.items(): + # Skip movies not seen (rating 0) + if rating == 0: + continue + rated_movie = MOVIE_INDEX.get(title) + if not rated_movie: + continue + # Normalize rating: 1 becomes 0 and 5 becomes 1. + norm_rating = (rating - 1) / 4 + sim = advanced_similarity(candidate, rated_movie, weights=weights) + weighted_scores.append(norm_rating * sim) + avg_score = sum(weighted_scores) / len(weighted_scores) if weighted_scores else 0 + if avg_score > best_score: + best_score = avg_score + best_movie = candidate + return best_movie + +# -------------------- Helper Functions -------------------- +import json as pyjson + +def get_profile(): + """Return the current user's profile as a dict. + If not logged in, use session storage.""" + if 'user_id' in session: + user = User.query.get(session['user_id']) + if user: + try: + return pyjson.loads(user.profile) + except: + return {} + return session.get('profile', {}) + +def save_profile(profile): + """Save the profile to the database (if logged in) or session.""" + if 'user_id' in session: + user = User.query.get(session['user_id']) + if user: + user.profile = pyjson.dumps(profile) + db.session.commit() else: - liked_profile = np.zeros((1, movie_vectors.shape[1])) - if disliked_ids: - disliked_profile = np.asarray(movie_vectors[disliked_ids].mean(axis=0)) - else: - disliked_profile = np.zeros((1, movie_vectors.shape[1])) - - # Compute numeric averages for liked movies. - liked_years = [movies[i]['year_num'] for i in liked_ids if movies[i]['year_num'] > 0] - liked_runtimes = [movies[i]['runtime_num'] for i in liked_ids if movies[i]['runtime_num'] > 0] - liked_ratings = [movies[i].get('imdb_rating', 0) for i in liked_ids if movies[i].get('imdb_rating', 0)] - - avg_year = np.mean(liked_years) if liked_years else None - avg_runtime = np.mean(liked_runtimes) if liked_runtimes else None - avg_rating = np.mean(liked_ratings) if liked_ratings else None - - predictions = [] - # Tunable weights. - w_text = 0.5 - w_year = 0.1 - w_runtime = 0.1 - w_rating = 0.15 - w_popularity = 0.15 - - for movie in available: - i = movie['id'] - # TEXT SIMILARITY. - movie_vector = movie_vectors[i].toarray() - like_sim = cosine_similarity(movie_vector, liked_profile)[0][0] if np.linalg.norm(liked_profile) != 0 else 0 - dislike_sim = cosine_similarity(movie_vector, disliked_profile)[0][0] if np.linalg.norm(disliked_profile) != 0 else 0 - text_score = like_sim - dislike_sim - - # YEAR SIMILARITY. - year_score = 0 - if avg_year is not None and movie['year_num'] > 0: - diff_year = abs(movie['year_num'] - avg_year) - year_score = 1 - (diff_year / year_range) - - # RUNTIME SIMILARITY. - runtime_score = 0 - if avg_runtime is not None and movie['runtime_num'] > 0: - diff_runtime = abs(movie['runtime_num'] - avg_runtime) - runtime_score = 1 - (diff_runtime / runtime_range) - - # RATING SIMILARITY. - rating_score = 0 - movie_rating = movie.get('imdb_rating', 0) - if avg_rating is not None and movie_rating: - diff_rating = abs(movie_rating - avg_rating) - rating_score = 1 - (diff_rating / rating_range) - - # POPULARITY SCORE. - popularity_score = 0 - if movie['vote_count'] > 0: - popularity_score = math.log(movie['vote_count'] + 1) / math.log(max_vote + 1) - - # Final prediction score. - final_score = (w_text * text_score + - w_year * year_score + - w_runtime * runtime_score + - w_rating * rating_score + - w_popularity * popularity_score) - predictions.append((movie, final_score)) - - predictions.sort(key=lambda x: x[1], reverse=True) - return [pred[0] for pred in predictions[:num]] - -def enough_info(): - """ - Check if the user has rated at least 3 movies (like/dislike). - """ - rated = session.get('rated_movies', {}) - count = sum(1 for rating in rated.values() if rating in ['like', 'dislike']) - return count >= 3 + session['profile'] = profile +# -------------------- Routes -------------------- @app.route('/') -def home(): - session.setdefault('rated_movies', {}) # {movie_id: rating} - session.setdefault('asked_movies', []) # list of movie IDs already shown - return redirect(url_for('questionnaire')) +def index(): + if 'user_id' in session: + return redirect(url_for('survey')) + return redirect(url_for('login')) -@app.route('/questionnaire', methods=['GET', 'POST']) -def questionnaire(): +# ----- User Login/Register ----- +@app.route('/login', methods=['GET', 'POST']) +def login(): if request.method == 'POST': - current_ids = request.form.getlist("movie_id") - for movie_id in current_ids: - rating = request.form.get(f"rating_{movie_id}") - session['rated_movies'][movie_id] = rating - if int(movie_id) not in session['asked_movies']: - session['asked_movies'].append(int(movie_id)) - remaining = [m for m in movies if m['id'] not in session['asked_movies']] - if enough_info() or not remaining: - return redirect(url_for('recommend')) + username = request.form.get('username').strip() + password = request.form.get('password') + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + session['user_id'] = user.id + flash("Logged in successfully.", "success") + return redirect(url_for('survey')) else: - return redirect(url_for('questionnaire')) - else: - # Use prediction to select movies for the questionnaire. - selected_movies = get_predicted_movies(num=10) - if not selected_movies: - return redirect(url_for('recommend')) - return render_template('questionnaire.html', movies=selected_movies) + flash("Invalid username or password.", "danger") + return render_template('login.html') -def advanced_recommendations(): - """ - Compute an advanced hybrid recommendation score on unseen movies. - Only movies not already shown (asked) are considered. - Combines: - 1. Text similarity (TF‑IDF) between liked/disliked profiles. - 2. Year similarity. - 3. Runtime similarity. - 4. Rating similarity. - 5. Popularity (log-scaled vote count). - Returns the top 20 recommendations. - """ - rated = session.get('rated_movies', {}) - asked = set(session.get('asked_movies', [])) - # Only consider movies that haven't been shown to the user. - available = [m for m in movies if m['id'] not in asked] - if not available: - available = movies # Fallback if all movies have been shown. +@app.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST': + username = request.form.get('username').strip() + password = request.form.get('password') + if User.query.filter_by(username=username).first(): + flash("Username already exists.", "danger") + else: + user = User(username=username) + user.set_password(password) + user.profile = pyjson.dumps({}) + db.session.add(user) + db.session.commit() + flash("Registration successful. Please log in.", "success") + return redirect(url_for('login')) + return render_template('register.html') - liked_ids = [int(mid) for mid, rating in rated.items() if rating == 'like'] - disliked_ids = [int(mid) for mid, rating in rated.items() if rating == 'dislike'] - - if liked_ids: - liked_profile = np.asarray(movie_vectors[liked_ids].mean(axis=0)) +@app.route('/logout') +def logout(): + session.clear() + flash("Logged out.", "info") + return redirect(url_for('login')) + +# ----- Survey & Recommendation ----- +@app.route('/survey', methods=['GET', 'POST']) +def survey(): + if request.method == 'POST': + profile = {} + for key, value in request.form.items(): + if key.startswith("rating_"): + movie_title = key[len("rating_"):] + try: + rating = float(value) + except (ValueError, TypeError): + rating = 0 + profile[movie_title] = rating + # Merge with any existing profile. + current_profile = get_profile() + current_profile.update(profile) + save_profile(current_profile) + return redirect(url_for('recommend')) else: - liked_profile = np.zeros((1, movie_vectors.shape[1])) - if disliked_ids: - disliked_profile = np.asarray(movie_vectors[disliked_ids].mean(axis=0)) - else: - disliked_profile = np.zeros((1, movie_vectors.shape[1])) - - liked_years = [movies[i]['year_num'] for i in liked_ids if movies[i]['year_num'] > 0] - liked_runtimes = [movies[i]['runtime_num'] for i in liked_ids if movies[i]['runtime_num'] > 0] - liked_ratings = [movies[i].get('imdb_rating', 0) for i in liked_ids if movies[i].get('imdb_rating', 0)] - avg_year = np.mean(liked_years) if liked_years else None - avg_runtime = np.mean(liked_runtimes) if liked_runtimes else None - avg_rating = np.mean(liked_ratings) if liked_ratings else None - - recommendations = [] - w_text = 0.5 - w_year = 0.1 - w_runtime = 0.1 - w_rating = 0.15 - w_popularity = 0.15 - - for movie in available: - i = movie['id'] - movie_vector = movie_vectors[i].toarray() - like_sim = cosine_similarity(movie_vector, liked_profile)[0][0] if np.linalg.norm(liked_profile) != 0 else 0 - dislike_sim = cosine_similarity(movie_vector, disliked_profile)[0][0] if np.linalg.norm(disliked_profile) != 0 else 0 - text_score = like_sim - dislike_sim - - year_score = 0 - if avg_year is not None and movie['year_num'] > 0: - diff_year = abs(movie['year_num'] - avg_year) - year_score = 1 - (diff_year / year_range) - - runtime_score = 0 - if avg_runtime is not None and movie['runtime_num'] > 0: - diff_runtime = abs(movie['runtime_num'] - avg_runtime) - runtime_score = 1 - (diff_runtime / runtime_range) - - rating_score = 0 - movie_rating = movie.get('imdb_rating', 0) - if avg_rating is not None and movie_rating: - diff_rating = abs(movie_rating - avg_rating) - rating_score = 1 - (diff_rating / rating_range) - - popularity_score = 0 - if movie['vote_count'] > 0: - popularity_score = math.log(movie['vote_count'] + 1) / math.log(max_vote + 1) - - final_score = (w_text * text_score + - w_year * year_score + - w_runtime * runtime_score + - w_rating * rating_score + - w_popularity * popularity_score) - recommendations.append((movie, final_score)) - - recommendations.sort(key=lambda x: x[1], reverse=True) - return recommendations[:20] + # Show 10 random movies for the survey. + sample_movies = random.sample(MOVIES, min(10, len(MOVIES))) + return render_template('survey.html', movies=sample_movies) @app.route('/recommend') def recommend(): - recommendations = advanced_recommendations() - return render_template('recommendations.html', recommendations=recommendations) + profile = get_profile() + movie = recommend_movie(profile) + return render_template('recommend.html', movie=movie) + +@app.route('/feedback', methods=['POST']) +def feedback(): + movie_title = request.form.get("movie_title") + try: + rating = float(request.form.get("rating")) + except (ValueError, TypeError): + rating = 0 + profile = get_profile() + profile[movie_title] = rating + save_profile(profile) + return redirect(url_for('recommend')) if __name__ == '__main__': app.run(debug=True) - - diff --git a/instance/users.db b/instance/users.db new file mode 100644 index 0000000..a433981 Binary files /dev/null and b/instance/users.db differ diff --git a/top_movies.json b/movies.json similarity index 100% rename from top_movies.json rename to movies.json diff --git a/out.html b/out.html deleted file mode 100644 index 6d8855d..0000000 --- a/out.html +++ /dev/null @@ -1,1584 +0,0 @@ -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
-
-
-
-
diff --git a/test.py b/scrapper.py similarity index 99% rename from test.py rename to scrapper.py index 3d1258a..71f0b0e 100644 --- a/test.py +++ b/scrapper.py @@ -8,7 +8,7 @@ import concurrent.futures api_key = "96f3424d6fe55c2982e6e094416607f5" # Output file where results are saved incrementally -output_filename = "top_movies.json" +output_filename = "movies.json" def write_movies(movies, filename=output_filename): """Helper function to write the movies list to a JSON file.""" diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..99d92c3 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,48 @@ + + + + + {% block title %}Movie Recommender{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + + diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index fce87c7..0000000 --- a/templates/index.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - Movie Slideshow - - - -

Rate Movies

-
- - {% for movie in movies %} - - {% endfor %} - -
- Movie Poster -

-

-
- -
- - - -
-
- - - - diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d7e5b92 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Login{% endblock %} +{% block content %} +

Login

+
+
+ + +
+
+ + +
+ +
+

Don't have an account? Register here.

+{% endblock %} diff --git a/templates/questionnaire.html b/templates/questionnaire.html deleted file mode 100644 index 93a251e..0000000 --- a/templates/questionnaire.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - Movie Questionnaire - - - -

Rate Movies

-
- -
-
- Movie Poster -

-

-
-
- - - -
-
- - - - diff --git a/templates/recommend.html b/templates/recommend.html new file mode 100644 index 0000000..b37afd7 --- /dev/null +++ b/templates/recommend.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Recommendation{% endblock %} +{% block content %} +

Your Recommended Movie

+ {% if movie %} +
+
+ {% if movie.poster %} +
+ {{ movie.title }} +
+ {% endif %} +
+
+
{{ movie.title }} ({{ movie.year }})
+

{{ movie.description }}

+
+
+
+
+

Have you seen this movie? If yes, rate it from 1 (disliked) to 5 (loved); otherwise select "Not Seen".

+
+ +
+ + + + + + +
+
+ {% else %} + + Restart Survey + {% endif %} +{% endblock %} diff --git a/templates/recommendations.html b/templates/recommendations.html deleted file mode 100644 index e3125bf..0000000 --- a/templates/recommendations.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - Movie Recommendations - - -

Your Movie Recommendations

- {% for movie, score in recommendations %} -
- {{ movie.title }} - {{ movie.title }} ({{ movie.year }}) -

{{ movie.description }}

- More Info -

Recommendation Score: {{ score | round(3) }}

-
-
- {% endfor %} - Back to Questionnaire - - diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..f0906de --- /dev/null +++ b/templates/register.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Register{% endblock %} +{% block content %} +

Register

+
+
+ + +
+
+ + +
+ +
+

Already have an account? Login here.

+{% endblock %} diff --git a/templates/survey.html b/templates/survey.html new file mode 100644 index 0000000..ac28075 --- /dev/null +++ b/templates/survey.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Movie Survey{% endblock %} +{% block content %} +

Initial Movie Survey

+

Please rate the following movies on a scale from 1 (disliked) to 5 (loved), or choose "Not Seen".

+
+ {% for movie in movies %} +
+
+ {% if movie.poster %} +
+ {{ movie.title }} +
+ {% endif %} +
+
+
{{ movie.title }} ({{ movie.year }})
+

{{ movie.description }}

+
+ + +
+ {% for i in range(1, 6) %} +
+ + +
+ {% endfor %} +
+
+
+
+ {% endfor %} + +
+{% endblock %}