diff --git a/.gitignore b/.gitignore index 4119916..36dd06b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ data data.db +indexdir diff --git a/src/app.py b/src/app.py index 838dce8..c7dcde1 100644 --- a/src/app.py +++ b/src/app.py @@ -1,14 +1,17 @@ -from flask import Flask, flash, render_template, redirect, abort, url_for +from flask import Flask, flash, render_template, redirect, abort, url_for, \ + request from flask_ckeditor import CKEditor from flask_login import current_user, login_user, LoginManager, logout_user, \ login_required from flask_wtf import CSRFProtect import os +from werkzeug.exceptions import HTTPException import config from content import rating_to_star from database import Database -from forms import LoginForm, RegisterForm, WriteForm +from forms import LoginForm, RegisterForm, WriteForm, SearchForm +from search import create_search_index, ft_search app = Flask(__name__) @@ -23,20 +26,55 @@ login = LoginManager(app) login.login_view = "login" +@login.user_loader +def load_user(ident): + """ + Returns a user by id. + + Parameters: + ident(int): id of the user + + Returns: + User: user that matches the id, None if none matches the id + """ + user = db.get_user_by_id(ident) + if user is not None: + return user + return None + + @app.context_processor def inject_title(): - return dict(title=config.TITLE, style=config.STYLE, \ - description=config.DESCRIPTION, \ + """ + Injects variables to the jinja2 templates. + + Returns: + dict: dictionary of variables to inject. + """ + return dict(title=config.TITLE, style=config.STYLE, + description=config.DESCRIPTION, registration=config.ALLOW_REGISTRATION, r_to_star=rating_to_star) -@app.errorhandler(404) +@app.errorhandler(HTTPException) def page_not_found(e): - return render_template("error.html", errorcode="404"), 404 + """ + Renders the error pages. + + Returns: + str: html formatted Error page + """ + return render_template("error.html", errorcode=str(e.code) + " " + e.name), e.code @app.route("/") def index(): + """ + Renders the index page. + + Returns: + str: html formatted index page + """ entries = db.get_entries() entries.reverse() return render_template("index.html", entries=entries) @@ -44,6 +82,12 @@ def index(): @app.route("/archive") def archive(): + """ + Renders the archive page. + + Returns: + str: html formatted archive page + """ entries = db.get_entries() entries.sort(key=lambda y: y.item.name) entries.reverse() @@ -54,7 +98,16 @@ def archive(): @app.route("/user/") def user(name): - entries = db.get_entries_by_user(name) + """ + Renders the user page of a specific user. + + Parameters: + name(str): name of the user + + Returns: + str: html formatted user page + """ + entries = db.get_entries_by_username(name) entries.sort(key=lambda y: y.item.name) entries.reverse() entries.sort(key=lambda y: y.item.date) @@ -66,6 +119,15 @@ def user(name): @app.route("/entry/") def entry(ident): + """ + Renders the entry page of a specific entry. + + Parameters: + ident(str): ident of the entry + + Returns: + str: html formatted entry page + """ entry = db.get_entry_by_id(ident) if entry is not None: return render_template("standalone.html", entry=entry) @@ -74,22 +136,43 @@ def entry(ident): @app.route("/feed") def feed(): + """ + Renders the rss feed of a the feed. + + Returns: + str: xml formatted feed + """ entries = db.get_entries() entries.reverse() rss_xml = render_template("rss.xml", entries=entries) return rss_xml -@login.user_loader -def load_user(ident): - user = db.get_user_by_id(ident) - if user is not None: - return user - return None +@app.route("/search", methods=["GET", "POST"]) +def search(): + """ + Renders the search page. + + Returns: + str: html formatted search page. + """ + form = SearchForm() + if request.method == "POST": + query_str = request.form["query_str"] + query_res = ft_search(query_str) + return render_template("search.html", form=form, results=query_res), 200 + return render_template("search.html", form=form, content=""), 200 @app.route("/login", methods=["GET", "POST"]) def login(): + """ + Logs the user in. + + Returns: + str: html formatted login page, if login is successful renders the index + page. + """ if current_user.is_authenticated: return redirect(url_for("index")) form = LoginForm() @@ -107,12 +190,25 @@ def login(): @app.route('/logout') def logout(): + """ + Logs out the current user. + + Returns: + str: html formatted index page. + """ logout_user() return redirect(url_for("index")) @app.route("/register", methods=["GET", "POST"]) def register(): + """ + Registers new users. + + Returns: + str: html formatted registration page, if registration is successful + renders the index page. + """ if current_user.is_authenticated or not config.ALLOW_REGISTRATION: return redirect(url_for("index")) form = RegisterForm() @@ -132,12 +228,20 @@ def register(): @app.route("/write_entry", methods=["GET", "POST"]) @login_required def write_entry(): + """ + Stores newly written entries. + + Returns: + str: html formatted write entry page, if posting of the entry is successful + renders the index page. + """ if not current_user.is_authenticated: return redirect(url_for("index")) form = WriteForm() if form.validate_on_submit(): db.insert_entry(form.name.data, form.date.data, form.text.data, form.rating.data, current_user.id) + create_search_index() return redirect(url_for("index")) return render_template("write.html", form=form) @@ -145,10 +249,17 @@ def write_entry(): @app.route("/delete_entry/", methods=["GET", "POST"]) @login_required def delete_entry(ident): + """ + Deletes an existing entry. + + Returns: + str: html formatted index entry page. + """ if not current_user.is_authenticated: return redirect(url_for("index")) if current_user.id == db.get_entry_by_id(ident).user.id: db.delete_entry(ident) + create_search_index() return redirect(url_for("index")) diff --git a/src/config.py b/src/config.py index 8558dfd..d0d036c 100644 --- a/src/config.py +++ b/src/config.py @@ -12,3 +12,9 @@ STYLE = "dark" # Allow new registrations ALLOW_REGISTRATION = True + +# Location of the search-indexing directory +INDEX_DIR = "indexdir" + +# Number of search results to show +SEARCH_NUMBER = 10 diff --git a/src/content.py b/src/content.py index fc586e6..8507f39 100644 --- a/src/content.py +++ b/src/content.py @@ -8,10 +8,10 @@ def rating_to_star(rating): Creates a string with stars based on the rating. Parameters: - rating: rating with minimum of 0 and maximum of 100. + rating: rating with minimum of 0 and maximum of 100 Returns: - string: unicode-formatted star-rating string. + string: unicode-formatted star-rating string """ res = u"\u272D"*int(rating/20) length = len(res) diff --git a/src/database.py b/src/database.py index ea472ef..5ccd5e9 100644 --- a/src/database.py +++ b/src/database.py @@ -5,6 +5,17 @@ from werkzeug.security import generate_password_hash, check_password_hash class User(): + """ + A class to represent a user. + + Attributes: + name (str): name of the user + id (int): id of the user + is_active (bool): check if the user is active + is_authenticated (bool): check if the user is logged in + is_anonymous (bool): check if the user is is_anonymous + pass_hash (str): hash of the users password + """ def __init__(self, name, pass_hash=None): self.name = name @@ -15,19 +26,61 @@ class User(): self.pass_hash = pass_hash def set_password(self, password): + """ + Set the password hash of the user from a password. + + Parameters: + password (str): password to add to the user + + Returns: + None + """ self.pass_hash = generate_password_hash(password) def set_id(self, ident): + """ + Set the id of the user. + + Parameters: + id (str): id to add to the user + + Returns: + None + """ self.id = ident def check_password(self, password): + """ + Check if a given password matches the one of the users by comparing the + hashes. + + Parameters: + password (str): password to compare the users password to + + Returns: + bool: True if it matches the users password, False otherwise + """ return check_password_hash(self.pass_hash, password) def get_id(self): + """ + Get the id of the user. + + Returns: + int: id of the user + """ return self.id class Item(): + """ + A class to represent an item. + + Attributes: + name (str): name of the item + id (int): id of the item + date (str): date the item was created + """ def __init__(self, name, date): self.name = name @@ -35,29 +88,73 @@ class Item(): self.id = None def set_id(self, ident): + """ + Set the id of the item. + + Returns: + int: id of the item + """ self.id = ident class Entry(): - - def __init__(self, text, rating, reviewed): + """ + A class to represent an entry. + + Attributes: + text (str): text of the entry + rating (int): rating of the item + date (str): date the entry was created + item (Item): item that is referenced by the entry + user (User): user that authored the entry + id (int): id of the item + """ + + def __init__(self, text, rating, date): self.text = text self.rating = rating - self.reviewed = reviewed + self.date = date self.item = None self.user = None def set_id(self, ident): + """ + Set the id of the entry. + + Parameters: + ident(int): id of the entry + """ self.id = ident def set_item(self, item): + """ + Set the item of the entry. + + Parameters: + item(Item): item of the entry + """ self.item = item def set_user(self, user): + """ + Set the user of the entry. + + Parameters: + user(User): user of the entry + """ self.user = user class Database: + """ + A class to represent an entry. + + Attributes: + USER_TABLE_FILE (str): name of the user table + ENTRY_TABLE_FILE (str): name of the entry table + ITEM_TABLE_FILE (str): name of the item table + DB_DIR(PathLike): path that leads to the directory containing the database + """ def __init__(self): self.USER_TABLE_FILE = 'USERS' @@ -70,12 +167,17 @@ class Database: """ Connect to an existing database instance based on the object attributes. + + Return: + Connection: connection to the database """ path = os.path.join(self.DB_DIR, "data.db") return sqlite3.connect(path) def setup_db(self): - """Creates a database with tables.""" + """ + Creates a database with the needed tables if it doesn't already exits. + """ db = self.connect() crs = db.cursor() query = "CREATE TABLE IF NOT EXISTS " + self.USER_TABLE_FILE + \ @@ -95,11 +197,21 @@ class Database: "text TEXT NOT NULL," + \ "rating INTEGER NOT NULL," +\ "user_id INTEGER REFERENCES " + self.USER_TABLE_FILE + "(id),"\ - "reviewed CHAR(10) NOT NULL)" + "date CHAR(10) NOT NULL)" crs.execute(query) db.commit() def insert_user(self, username, password): + """ + Insert a row in the user table. + + Parameters: + username (str): name of the user to add + password (str): password of the user to add + + Returns: + int: number of the line the row was added, None if it wasn't successful + """ pass_hash = generate_password_hash(password) if self.get_user_by_name(username) is None and pass_hash is not None: db = self.connect() @@ -113,6 +225,19 @@ class Database: return None def insert_entry(self, name, date, text, rating, user_id=None): + """ + Insert a row in the entry table. + + Parameters: + name (str): name of the entry to add + date (str): date of the entry to add + text (str): text of the entry to add + rating (str): rating of the entry to add + user_id (int): id of the user referenced by the entry to add + + Returns: + int: number of the line the row was added + """ db = self.connect() crs = db.cursor() query = "INSERT OR IGNORE INTO " + self.ITEM_TABLE_FILE + \ @@ -122,15 +247,24 @@ class Database: " WHERE name = ? AND date = ?" crs.execute(query, (name, date)) item_id = crs.fetchone()[0] - reviewed = dt.today().strftime('%Y-%m-%d') + date = dt.today().strftime('%Y-%m-%d') query = "INSERT INTO " + self.ENTRY_TABLE_FILE + \ - "(`item_id`, `text`, `rating`, `user_id`, `reviewed`)" + \ + "(`item_id`, `text`, `rating`, `user_id`, `date`)" + \ "VALUES (?, ?, ?, ?, ?)" - crs.execute(query, (item_id, text, rating, user_id, reviewed)) + crs.execute(query, (item_id, text, rating, user_id, date)) db.commit() return crs.lastrowid def delete_entry(self, ident): + """ + Delete a row from the entry table based on the entrys id. + + Parameters: + ident (int): id of the entry to remove + + Returns: + int: number of the line the row was removed from + """ db = self.connect() crs = db.cursor() query = "DELETE FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?" @@ -139,16 +273,31 @@ class Database: return crs.lastrowid def get_entries(self): + """ + Return all the entries stored in the database. + + Return: + List(Entry): list of entries in database + """ db = self.connect() crs = db.cursor() query = "SELECT * FROM " + self.ENTRY_TABLE_FILE crs.execute(query) res = [] for item in crs.fetchall(): - res.append(self.db_to_entry(*item)) + res.append(self.entry_from_db(*item)) return res def get_entry_by_id(self, ident): + """ + Return an entry stored in the database based on the entrys id. + + Parameters: + ident (int): id of the entry to return + + Returns: + Entry: entry that matched the given id + """ db = self.connect() crs = db.cursor() query = "SELECT * FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?" @@ -157,21 +306,39 @@ class Database: if fetched is None: return None else: - return self.db_to_entry(*fetched) + return self.entry_from_db(*fetched) + + def get_entries_by_username(self, username): + """ + Return a entries stored in the database based on the entries name. - def get_entries_by_user(self, name): + Parameters: + username (str): name of the entries to return + + Returns: + List(Entry): entries that matched the given name + """ db = self.connect() crs = db.cursor() query = "SELECT * FROM " + self.ENTRY_TABLE_FILE + \ " WHERE user_id = (SELECT id FROM " + self.USER_TABLE_FILE + \ " WHERE name = ?)" - crs.execute(query, (name, )) + crs.execute(query, (username, )) res = [] for item in crs.fetchall(): - res.append(self.db_to_entry(*item)) + res.append(self.entry_from_db(*item)) return res def get_item_by_id(self, ident): + """ + Return an item stored in the database based on the items id. + + Parameters: + ident (int): id of the item to return + + Returns: + Item: item that matched the given id + """ db = self.connect() crs = db.cursor() query = "SELECT * FROM " + self.ITEM_TABLE_FILE + " WHERE id = ?" @@ -180,9 +347,18 @@ class Database: if fetched is None: return None else: - return self.db_to_item(*fetched) + return self.item_from_db(*fetched) def get_user_by_id(self, ident): + """ + Return a user stored in the database based on the users id. + + Parameters: + ident (int): id of the user to return + + Returns: + Item: user that matched the given id + """ db = self.connect() crs = db.cursor() query = "SELECT * FROM " + self.USER_TABLE_FILE + " WHERE id = ?" @@ -191,9 +367,18 @@ class Database: if fetched is None: return None else: - return self.db_to_user(*fetched) + return self.user_from_db(*fetched) def get_user_by_name(self, name): + """ + Return a user stored in the database based on the user name. + + Parameters: + name (str): name of the user to return + + Returns: + Entry: user that matched the given name + """ db = self.connect() crs = db.cursor() query = "SELECT * FROM " + self.USER_TABLE_FILE + " WHERE name = ?" @@ -202,20 +387,56 @@ class Database: if fetched is None: return None else: - return self.db_to_user(*fetched) + return self.user_from_db(*fetched) - def db_to_user(self, ident, name, pass_hash): + def user_from_db(self, ident, name, pass_hash): + """ + Return a user from given database parameters. + + Parameters: + ident: id of the user + name: text of the user + pass_hash: password hash of the user + + Returns: + User: user element with given variables + """ user = User(name, pass_hash) user.set_id(ident) return user - def db_to_item(self, ident, name, date): + def item_from_db(self, ident, name, date): + """ + Return an item from given database parameters. + + Parameters: + ident: id of the item + name: text of the item + date: date of the day the item was created + + Returns: + Item: entry element with given variables + """ item = Item(name, date) item.set_id(ident) return item - def db_to_entry(self, ident, item_id, text, rating, user_id, reviewed): - entry = Entry(text, rating, reviewed) + def entry_from_db(self, ident, item_id, text, rating, user_id, date): + """ + Return an entry from given database parameters. + + Parameters: + ident: id of the entry + item_id: id of the referenced item + text: text of the entry + rating: rating of the entry + user_id: id of the user that authored the entry + date: date of the day the entry was written + + Returns: + Entry: entry element with given variables + """ + entry = Entry(text, rating, date) entry.set_id(ident) entry.set_item(self.get_item_by_id(item_id)) entry.set_user(self.get_user_by_id(user_id)) diff --git a/src/forms.py b/src/forms.py index 44d079d..2ed3369 100644 --- a/src/forms.py +++ b/src/forms.py @@ -1,13 +1,16 @@ from datetime import date from flask_ckeditor import CKEditorField from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, SubmitField +from wtforms import StringField, PasswordField, SubmitField, TextField from wtforms.fields.html5 import IntegerField from wtforms.validators import DataRequired, EqualTo, InputRequired, \ NumberRange, ValidationError, Length class LoginForm(FlaskForm): + """ + A Class for the Form that is used while logging in. + """ username = StringField("Username", validators=[DataRequired(), Length(min=4, max=32)]) password = PasswordField("Password", validators=[DataRequired(), @@ -16,6 +19,9 @@ class LoginForm(FlaskForm): class RegisterForm(FlaskForm): + """ + A Class for the Form that is used while registering. + """ username = StringField("Username", validators=[DataRequired(), Length(min=4, max=32)]) password = PasswordField("Password", validators=[DataRequired(), @@ -25,7 +31,19 @@ class RegisterForm(FlaskForm): submit = SubmitField("Register") +class SearchForm(FlaskForm): + """ + A Class for the Form that is used while searching. + """ + query_str = TextField( + "Query", [DataRequired("Please enter the search term")]) + submit = SubmitField("Search") + + class WriteForm(FlaskForm): + """ + A Class for the Form that is used while writing a new entry. + """ name = StringField("Name", validators=[DataRequired(), Length(min=2, max=64)]) date = IntegerField("Release Year", default=date.today().year, validators=[ @@ -37,5 +55,17 @@ class WriteForm(FlaskForm): submit = SubmitField("Publish") def validate_text(self, text): + """ + Validate a given input for html level one headers. + + Parameters: + text (str): text to validate + + Returns: + None + + Raises: + ValidatenError: if the text contains a first level html tag + """ if "

" in text.data or "

" in text.data: raise ValidationError("Headings on level 1 are not permitted.") diff --git a/src/indexdir/.gitkeep b/src/indexdir/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/search.py b/src/search.py new file mode 100644 index 0000000..8bd0134 --- /dev/null +++ b/src/search.py @@ -0,0 +1,80 @@ +import os +import re + +from whoosh import scoring +from whoosh.index import create_in, open_dir +from whoosh.fields import Schema, TEXT, ID +from whoosh.qparser import QueryParser + +import config +from database import Database + +CLEANR = re.compile('<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});') + + +def remove_html_tags(text): + """ + Convert a text from html formatted to unformatted. + + Parameters: + text (str): text to clean + + Returns: + str: text without html tags + """ + res = re.sub(CLEANR, '', text) + return res + + +def create_search_index(): + """ + Create the index data to search all entries. + """ + db = Database() + schema = Schema(title=TEXT(stored=True), + path=ID(stored=True), content=TEXT(stored=True)) + if not os.path.exists(config.INDEX_DIR): + os.mkdir(config.INDEX_DIR) + ix = create_in(config.INDEX_DIR, schema) + writer = ix.writer() + for entry in db.get_entries(): + path = str(entry.id) + text = entry.item.name + " " + entry.item.date + " " + entry.text + \ + " by " + entry.user.name + " " + entry.date + writer.add_document(title=entry.item.name, path=path, content=text) + writer.commit() + + +def ft_search_times(query_str, number): + """ + Search for a given term and returns a specific amount of results. + + Parameters: + query_str (str): term to search for + number (int): number of results to return + + Returns: + List(Entry): list of entries that matched the search + """ + ix = open_dir(config.INDEX_DIR) + results = [] + db = Database() + with ix.searcher(weighting=scoring.BM25F) as s: + query = QueryParser("content", ix.schema).parse(query_str) + matches = s.search(query, limit=number) + for match in matches: + results.append(db.get_entry_by_id(match["path"])) + return results + + +def ft_search(query_str): + """ + Search for a given term and show the predefined amount of results. + + Parameters: + query_str (str): term to search for + + Returns: + List(Entry): list of entries that matched the search + """ + return ft_search_times(query_str, config.SEARCH_NUMBER) diff --git a/src/templates/archive.html b/src/templates/archive.html index 5662f07..4964297 100644 --- a/src/templates/archive.html +++ b/src/templates/archive.html @@ -27,7 +27,7 @@ {% set ns.open_li = True -%} {% endif -%} - {{ entry.reviewed }} {{ r_to_star(entry.rating) }} by {{ entry.user.name }} + {{ entry.date }} {{ r_to_star(entry.rating) }} by {{ entry.user.name }} {% set ns.prev_item_date = entry.item.date -%} {% set ns.prev_item_id = entry.item.id -%} diff --git a/src/templates/index.html b/src/templates/index.html index 11f2bf8..f3f32e7 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -15,7 +15,7 @@ rated {{ entry.rating }}/100 by {{ entry.user.name }} - on {{ entry.reviewed }} + on {{ entry.date }}
{% autoescape off -%} {{ entry.text }} diff --git a/src/templates/rss.xml b/src/templates/rss.xml index 7cf62be..bc46e4e 100644 --- a/src/templates/rss.xml +++ b/src/templates/rss.xml @@ -16,7 +16,7 @@ {{ url_for("index", _anchor=entry.id, _external=True) }} - {{ entry.reviewed }} + {{ entry.date }} {% autoescape off -%} diff --git a/src/templates/search.html b/src/templates/search.html new file mode 100644 index 0000000..3c3c03a --- /dev/null +++ b/src/templates/search.html @@ -0,0 +1,23 @@ +{% extends "template.html" -%} + +{% block content %} +
+

Search


+ +
+{% endblock -%} diff --git a/src/templates/standalone.html b/src/templates/standalone.html index 7e67a1b..00fc807 100644 --- a/src/templates/standalone.html +++ b/src/templates/standalone.html @@ -14,7 +14,7 @@ on - {{ entry.reviewed }} + {{ entry.date }}
{% if current_user.id == entry.user.id -%} diff --git a/src/templates/template.html b/src/templates/template.html index f929395..44026b2 100644 --- a/src/templates/template.html +++ b/src/templates/template.html @@ -1,6 +1,7 @@ {% set navigation_bar = [ - (url_for("index"), 'index', 'Blog'), - (url_for("archive"), 'archive', 'Archive') + (url_for("index"), "index", "Blog"), + (url_for("archive"), "archive", "Archive"), + (url_for("search"), "search", "Search") ] -%} diff --git a/src/templates/user.html b/src/templates/user.html index 904ef4b..88f1e8a 100644 --- a/src/templates/user.html +++ b/src/templates/user.html @@ -27,7 +27,7 @@ {% set ns.open_li = True -%} {% endif -%} - {{ entry.reviewed }} {{ r_to_star(entry.rating) }} + {{ entry.date }} {{ r_to_star(entry.rating) }} {% set ns.prev_item_date = entry.item.date -%} {% set ns.prev_item_id = entry.item.id -%}