1.0.0: Edit functionality added

This commit is contained in:
2026-05-10 02:38:19 +02:00
parent 39d34bf51c
commit 4905e662f0
23 changed files with 1094 additions and 740 deletions

110
README.md
View File

@@ -2,57 +2,81 @@
![container-critique-logo](container-critique.png) ![container-critique-logo](container-critique.png)
This is a blog based on Pythons Flask framework. Container Critique is a blog based on Pythons Flask framework.
The blog is intended to be used to review and critique things. The blog is intended to be used to review and critique movies, books and similar media.
## Features/To-Dos ## Features
- [x] User Management
- [x] Accounts - [x] Registration
- [x] Login - [x] Login
- [x] Logout - [x] Logout
- [x] Register - [x] User profile page
- [x] User Page
- [ ] Review blog entries - [x] Blog Functionality
- [x] Writing entries - [x] Create entries
- [ ] Editing entries - [x] Edit entries
- [x] Deleting entries - [x] Delete entries
- [ ] Better interface for writing, editing, deleting, login - [x] Standalone article pages
- [x] Infinite-scroll blog page - [x] Links back to main blog page
- [x] Archive page - [x] Infinite-scroll main page
- [x] Months as headings - [x] Archive page
- [x] Links to scrolling blog page - [x] Monthly grouping
- [x] Links to standalone article - [x] Links to main blog page
- [x] Standalone article page - [x] Links to standalone articles
- [x] Links to scrolling blog page
- [x] Search page - [x] Search
- [x] Full-text search - [x] Full-text search
- [x] RSS feed
- [x] Eye candy - [x] Syndication
- [x] Star rating - [x] RSS feed
- [x] User Interface
- [x] Responsive navigation
- [x] Header
- [x] Footer
- [x] Rich text editor - [x] Rich text editor
- [x] CSS for all tags - [x] Star ratings
- [x] Navigation - [x] Styling for common HTML tags
- [x] Header - [x] Theme support
- [x] Footer - [x] Dark theme
- [x] Switchable CSS - [x] Light theme
- [x] CSS dark-theme - [x] Logo
- [x] CSS light-theme
- [x] Docker installation - [x] Deployment
- [x] Logo - [x] Basic Python
- [x] Docker
## To-Dos
- [ ] Quality of Life Improvements
- [ ] Improved UI for writing entries
- [ ] Improved UI for editing entries
- [ ] Improved UI for deleting entries
- [ ] Improved UI for login flow
## Usage ## Usage
## Deployment ## Deployment
### uv (Recommended Over PIP)
* install [uv](https://github.com/astral-sh/uv?utm_source=chatgpt.com)
* `git clone https://github.com/tiyn/container-critique`
* `cd container-critique`
* install the dependencies with `uv sync`
* edit the `src/config.py` file according to your needs
* run `uv run python src/app.py`
* blog is available on port 5000
### PIP/Python ### PIP/Python
- `git clone https://github.com/tiyn/container-critique` * `git clone https://github.com/tiyn/container-critique`
- `cd container-critique/src` * `cd container-critique/src`
- edit the `config.py` file according to your needs * edit the `config.py` file according to your needs
- `pip3install -r requirements.txt` - install depenencies * install dependencies with `pip install -r requirements.txt`
- run `python app.py` * run `python app.py`
- blog is available on port 5000 * blog is available on port 5000
### Docker ### Docker
@@ -63,11 +87,11 @@ The `config.py` can be found in the `src` folder.
Set the following volumes with the -v tag. Set the following volumes with the -v tag.
| Volume-Name | Container mount | Description | | Volume-Name | Container mount | Description |
| ------------- | ---------------------- | ---------------------------------- | | ------------- | ---------------------- | ---------------------------------- |
| `config-file` | `/blog/config.py` | Config file | | `config-file` | `/blog/config.py` | Config file |
| `data` | `/blog/data` | Directory for data | | `data` | `/blog/data` | Directory for data |
| `css` | `/blog/static/css` | (optional) Directory for css files | | `css` | `/blog/static/css` | (optional) Directory for css files |
#### Ports #### Ports
@@ -75,7 +99,7 @@ Set the following ports with the -p tag.
| Container-Port | Recommended outside port | Protocol | Description | | Container-Port | Recommended outside port | Protocol | Description |
| -------------- | ------------------------ | -------- | ----------- | | -------------- | ------------------------ | -------- | ----------- |
| `5000` | `80` | TCP | HTTP port | | `5000` | `80` | TCP | HTTP port |
#### Example run-command #### Example run-command

11
pyproject.toml Normal file
View File

@@ -0,0 +1,11 @@
[project]
name = "container-critique"
version = "1.0.0"
requires-python = ">=3.14"
dependencies = [
"bleach>=6.3.0",
"flask-ckeditor>=1.0.0",
"flask-login>=0.6.3",
"flask-wtf>=1.3.0",
"whoosh>=2.7.4",
]

View File

@@ -1,19 +1,17 @@
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 import os
from flask import (Flask, abort, flash, redirect, render_template, request, url_for)
from flask_ckeditor import CKEditor
from flask_login import (LoginManager, current_user, login_required, login_user, logout_user)
from flask_wtf import CSRFProtect
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
import config import config
from content import rating_to_star from content import rating_to_star
from database import Database from database import Database
from forms import LoginForm, RegisterForm, WriteForm, SearchForm from forms import LoginForm, RegisterForm, SearchForm, WriteForm
from search import create_search_index, ft_search from search import create_search_index, ft_search
app = Flask(__name__) app = Flask(__name__)
csrf = CSRFProtect() csrf = CSRFProtect()
db = Database() db = Database()
@@ -28,7 +26,7 @@ login.login_view = "login"
@login.user_loader @login.user_loader
def load_user(ident): def load_user(ident):
""" """
Returns a user by id. Returns a user by id.
Parameters: Parameters:
@@ -37,68 +35,70 @@ def load_user(ident):
Returns: Returns:
User: user that matches the id, None if none matches the id User: user that matches the id, None if none matches the id
""" """
user = db.get_user_by_id(ident) user = db.get_user_by_id(ident)
if user is not None: if user is not None:
return user return user
return None return None
@app.context_processor @app.context_processor
def inject_title(): def inject_title():
""" """
Injects variables to the jinja2 templates. Injects variables to the jinja2 templates.
Returns: Returns:
dict: dictionary of variables to inject. dict: dictionary of variables to inject.
""" """
return dict(title=config.TITLE, style=config.STYLE, return dict(title=config.TITLE,
description=config.DESCRIPTION, style=config.STYLE,
registration=config.ALLOW_REGISTRATION, r_to_star=rating_to_star) description=config.DESCRIPTION,
registration=config.ALLOW_REGISTRATION,
r_to_star=rating_to_star)
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def page_not_found(e): def page_not_found(e):
""" """
Renders the error pages. Renders the error pages.
Returns: Returns:
str: html formatted Error page str: html formatted Error page
""" """
return render_template("error.html", errorcode=str(e.code) + " " + e.name), e.code return render_template("error.html", errorcode=str(e.code) + " " + e.name), e.code
@app.route("/") @app.route("/")
def index(): def index():
""" """
Renders the index page. Renders the index page.
Returns: Returns:
str: html formatted index page str: html formatted index page
""" """
entries = db.get_entries() entries = db.get_entries()
entries.reverse() entries.reverse()
return render_template("index.html", entries=entries) return render_template("index.html", entries=entries)
@app.route("/archive") @app.route("/archive")
def archive(): def archive():
""" """
Renders the archive page. Renders the archive page.
Returns: Returns:
str: html formatted archive page str: html formatted archive page
""" """
entries = db.get_entries() entries = db.get_entries()
entries.sort(key=lambda y: y.item.name) entries.sort(key=lambda y: y.item.name)
entries.reverse() entries.reverse()
entries.sort(key=lambda y: y.item.date) entries.sort(key=lambda y: y.item.date)
entries.reverse() entries.reverse()
return render_template("archive.html", entries=entries) return render_template("archive.html", entries=entries)
@app.route("/user/<name>") @app.route("/user/<name>")
def user(name): def user(name):
""" """
Renders the user page of a specific user. Renders the user page of a specific user.
Parameters: Parameters:
@@ -107,19 +107,19 @@ def user(name):
Returns: Returns:
str: html formatted user page str: html formatted user page
""" """
entries = db.get_entries_by_username(name) entries = db.get_entries_by_username(name)
entries.sort(key=lambda y: y.item.name) entries.sort(key=lambda y: y.item.name)
entries.reverse() entries.reverse()
entries.sort(key=lambda y: y.item.date) entries.sort(key=lambda y: y.item.date)
entries.reverse() entries.reverse()
if entries != []: if entries != []:
return render_template("user.html", name=name, entries=entries) return render_template("user.html", name=name, entries=entries)
abort(404) abort(404)
@app.route("/entry/<ident>") @app.route("/entry/<ident>")
def entry(ident): def entry(ident):
""" """
Renders the entry page of a specific entry. Renders the entry page of a specific entry.
Parameters: Parameters:
@@ -128,140 +128,171 @@ def entry(ident):
Returns: Returns:
str: html formatted entry page str: html formatted entry page
""" """
entry = db.get_entry_by_id(ident) entry = db.get_entry_by_id(ident)
if entry is not None: if entry is not None:
return render_template("standalone.html", entry=entry) return render_template("standalone.html", entry=entry)
abort(404) abort(404)
@app.route("/feed") @app.route("/feed")
def feed(): def feed():
""" """
Renders the rss feed of a the feed. Renders the rss feed of a the feed.
Returns: Returns:
str: xml formatted feed str: xml formatted feed
""" """
entries = db.get_entries() entries = db.get_entries()
entries.reverse() entries.reverse()
rss_xml = render_template("rss.xml", entries=entries) rss_xml = render_template("rss.xml", entries=entries)
return rss_xml return rss_xml
@app.route("/search", methods=["GET", "POST"]) @app.route("/search", methods=["GET", "POST"])
def search(): def search():
""" """
Renders the search page. Renders the search page.
Returns: Returns:
str: html formatted search page. str: html formatted search page.
""" """
form = SearchForm() form = SearchForm()
if request.method == "POST": if request.method == "POST":
query_str = request.form["query_str"] query_str = request.form["query_str"]
query_res = ft_search(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, results=query_res), 200
return render_template("search.html", form=form, content=""), 200 return render_template("search.html", form=form, content=""), 200
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
""" """
Logs the user in. Logs the user in.
Returns: Returns:
str: html formatted login page, if login is successful renders the index str: html formatted login page, if login is successful renders the index
page. page.
""" """
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("index"))
form = LoginForm()
if form.validate_on_submit():
user = db.get_user_by_name(form.username.data)
print(user)
if user is not None:
if user.check_password(form.password.data):
login_user(user)
return redirect(url_for("index")) return redirect(url_for("index"))
form = LoginForm() flash("Invalid username or password.")
if form.validate_on_submit(): return redirect(url_for("login"))
user = db.get_user_by_name(form.username.data) return render_template("login.html", form=form)
print(user)
if user is not None:
if user.check_password(form.password.data):
login_user(user)
return redirect(url_for("index"))
flash("Invalid username or password.")
return redirect(url_for("login"))
return render_template("login.html", form=form)
@app.route('/logout') @app.route('/logout')
def logout(): def logout():
""" """
Logs out the current user. Logs out the current user.
Returns: Returns:
str: html formatted index page. str: html formatted index page.
""" """
logout_user() logout_user()
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route("/register", methods=["GET", "POST"]) @app.route("/register", methods=["GET", "POST"])
def register(): def register():
""" """
Registers new users. Registers new users.
Returns: Returns:
str: html formatted registration page, if registration is successful str: html formatted registration page, if registration is successful
renders the index page. renders the index page.
""" """
if current_user.is_authenticated or not config.ALLOW_REGISTRATION: if current_user.is_authenticated or not config.ALLOW_REGISTRATION:
return redirect(url_for("index"))
form = RegisterForm()
if form.validate_on_submit():
user = db.get_user_by_name(form.username.data)
if user is None:
ident = db.insert_user(form.username.data, form.password.data)
if ident is not None:
user = db.get_user_by_id(ident)
login_user(user)
return redirect(url_for("index")) return redirect(url_for("index"))
form = RegisterForm() flash("An error occured during registration.")
if form.validate_on_submit(): return redirect(url_for("register"))
user = db.get_user_by_name(form.username.data) return render_template("register.html", form=form)
if user is None:
ident = db.insert_user(form.username.data, form.password.data)
if ident is not None:
user = db.get_user_by_id(ident)
login_user(user)
return redirect(url_for("index"))
flash("An error occured during registration.")
return redirect(url_for("register"))
return render_template("register.html", form=form)
@app.route("/write_entry", methods=["GET", "POST"]) @app.route("/write_entry", methods=["GET", "POST"])
@login_required @login_required
def write_entry(): def write_entry():
""" """
Stores newly written entries. Stores newly written entries.
Returns: Returns:
str: html formatted write entry page, if posting of the entry is successful str: html formatted write entry page, if posting of the entry is successful
renders the index page. renders the index page.
""" """
if not current_user.is_authenticated: if not current_user.is_authenticated:
return redirect(url_for("index")) return redirect(url_for("index"))
form = WriteForm() form = WriteForm()
if form.validate_on_submit(): if form.validate_on_submit():
db.insert_entry(form.name.data, form.date.data, db.insert_entry(form.name.data, form.date.data, form.text.data, form.rating.data,
form.text.data, form.rating.data, current_user.id) current_user.id)
create_search_index() create_search_index()
return redirect(url_for("index")) return redirect(url_for("index"))
return render_template("write.html", form=form) return render_template("write.html", form=form)
@app.route("/delete_entry/<ident>", methods=["GET", "POST"]) @app.route("/delete_entry/<ident>", methods=["POST"])
@login_required @login_required
def delete_entry(ident): def delete_entry(ident):
""" """
Deletes an existing entry. Deletes an existing entry.
Returns: Returns:
str: html formatted index entry page. str: html formatted index entry page.
""" """
if not current_user.is_authenticated: 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")) 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"))
@app.route("/edit_entry/<ident>", methods=["GET", "POST"])
@login_required
def edit_entry(ident):
entry = db.get_entry_by_id(ident)
if entry is None:
abort(404)
if current_user.id != entry.user.id:
abort(403)
form = WriteForm()
if request.method == "GET":
form.name.data = entry.item.name
form.date.data = entry.item.date
form.text.data = entry.text
form.rating.data = entry.rating
if form.validate_on_submit():
db.update_entry(ident, form.name.data, form.date.data, form.text.data, form.rating.data)
create_search_index()
return redirect(url_for("entry", ident=ident))
return render_template("write.html", form=form, edit_mode=True)
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0") app.run(host="0.0.0.0")

View File

@@ -4,7 +4,7 @@ db = Database()
def rating_to_star(rating): def rating_to_star(rating):
""" """
Creates a string with stars based on the rating. Creates a string with stars based on the rating.
Parameters: Parameters:
@@ -13,10 +13,10 @@ def rating_to_star(rating):
Returns: Returns:
string: unicode-formatted star-rating string string: unicode-formatted star-rating string
""" """
res = u"\u272D"*int(rating/20) res = u"\u272D" * int(rating / 20)
length = len(res) length = len(res)
if rating/20 % 1 >= 0.5: if rating / 20 % 1 >= 0.5:
length += 1 length += 1
res += u" \u2BE8 " res += u" \u2BE8 "
res += (u"\u2606" * (5 - length)) res += (u"\u2606" * (5 - length))
return res return res

View File

@@ -1,11 +1,12 @@
from datetime import date as dt
import os import os
import sqlite3 import sqlite3
from werkzeug.security import generate_password_hash, check_password_hash from datetime import date as dt
from werkzeug.security import check_password_hash, generate_password_hash
class User(): class User():
""" """
A class to represent a user. A class to represent a user.
Attributes: Attributes:
@@ -17,16 +18,16 @@ class User():
pass_hash (str): hash of the users password pass_hash (str): hash of the users password
""" """
def __init__(self, name, pass_hash=None): def __init__(self, name, pass_hash=None):
self.name = name self.name = name
self.id = None self.id = None
self.is_active = True self.is_active = True
self.is_authenticated = True self.is_authenticated = True
self.is_anonymous = False self.is_anonymous = False
self.pass_hash = pass_hash self.pass_hash = pass_hash
def set_password(self, password): def set_password(self, password):
""" """
Set the password hash of the user from a password. Set the password hash of the user from a password.
Parameters: Parameters:
@@ -35,10 +36,10 @@ class User():
Returns: Returns:
None None
""" """
self.pass_hash = generate_password_hash(password) self.pass_hash = generate_password_hash(password)
def set_id(self, ident): def set_id(self, ident):
""" """
Set the id of the user. Set the id of the user.
Parameters: Parameters:
@@ -47,10 +48,10 @@ class User():
Returns: Returns:
None None
""" """
self.id = ident self.id = ident
def check_password(self, password): def check_password(self, password):
""" """
Check if a given password matches the one of the users by comparing the Check if a given password matches the one of the users by comparing the
hashes. hashes.
@@ -60,20 +61,20 @@ class User():
Returns: Returns:
bool: True if it matches the users password, False otherwise bool: True if it matches the users password, False otherwise
""" """
return check_password_hash(self.pass_hash, password) return check_password_hash(self.pass_hash, password)
def get_id(self): def get_id(self):
""" """
Get the id of the user. Get the id of the user.
Returns: Returns:
int: id of the user int: id of the user
""" """
return self.id return self.id
class Item(): class Item():
""" """
A class to represent an item. A class to represent an item.
Attributes: Attributes:
@@ -82,23 +83,23 @@ class Item():
date (str): date the item was created date (str): date the item was created
""" """
def __init__(self, name, date): def __init__(self, name, date):
self.name = name self.name = name
self.date = date self.date = date
self.id = None self.id = None
def set_id(self, ident): def set_id(self, ident):
""" """
Set the id of the item. Set the id of the item.
Returns: Returns:
int: id of the item int: id of the item
""" """
self.id = ident self.id = ident
class Entry(): class Entry():
""" """
A class to represent an entry. A class to represent an entry.
Attributes: Attributes:
@@ -110,43 +111,43 @@ class Entry():
id (int): id of the item id (int): id of the item
""" """
def __init__(self, text, rating, date): def __init__(self, text, rating, date):
self.text = text self.text = text
self.rating = rating self.rating = rating
self.date = date self.date = date
self.item = None self.item = None
self.user = None self.user = None
def set_id(self, ident): def set_id(self, ident):
""" """
Set the id of the entry. Set the id of the entry.
Parameters: Parameters:
ident(int): id of the entry ident(int): id of the entry
""" """
self.id = ident self.id = ident
def set_item(self, item): def set_item(self, item):
""" """
Set the item of the entry. Set the item of the entry.
Parameters: Parameters:
item(Item): item of the entry item(Item): item of the entry
""" """
self.item = item self.item = item
def set_user(self, user): def set_user(self, user):
""" """
Set the user of the entry. Set the user of the entry.
Parameters: Parameters:
user(User): user of the entry user(User): user of the entry
""" """
self.user = user self.user = user
class Database: class Database:
""" """
A class to represent an entry. A class to represent an entry.
Attributes: Attributes:
@@ -156,53 +157,53 @@ class Database:
DB_DIR(PathLike): path that leads to the directory containing the database DB_DIR(PathLike): path that leads to the directory containing the database
""" """
def __init__(self): def __init__(self):
self.USER_TABLE_FILE = 'USERS' self.USER_TABLE_FILE = 'USERS'
self.ENTRY_TABLE_FILE = 'ENTRIES' self.ENTRY_TABLE_FILE = 'ENTRIES'
self.ITEM_TABLE_FILE = 'ITEMS' self.ITEM_TABLE_FILE = 'ITEMS'
self.DB_DIR = os.path.dirname("./data/") self.DB_DIR = os.path.dirname("./data/")
self.setup_db() self.setup_db()
def connect(self): def connect(self):
""" """
Connect to an existing database instance based on the object Connect to an existing database instance based on the object
attributes. attributes.
Return: Return:
Connection: connection to the database Connection: connection to the database
""" """
path = os.path.join(self.DB_DIR, "data.db") path = os.path.join(self.DB_DIR, "data.db")
return sqlite3.connect(path) return sqlite3.connect(path)
def setup_db(self): def setup_db(self):
""" """
Creates a database with the needed tables if it doesn't already exits. Creates a database with the needed tables if it doesn't already exits.
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "CREATE TABLE IF NOT EXISTS " + self.USER_TABLE_FILE + \ query = "CREATE TABLE IF NOT EXISTS " + self.USER_TABLE_FILE + \
"(id INTEGER PRIMARY KEY AUTOINCREMENT," + \ "(id INTEGER PRIMARY KEY AUTOINCREMENT," + \
"name CHAR(32) NOT NULL UNIQUE," + \ "name CHAR(32) NOT NULL UNIQUE," + \
"password CHAR(32) NOT NULL)" "password CHAR(32) NOT NULL)"
crs.execute(query) crs.execute(query)
query = "CREATE TABLE IF NOT EXISTS " + self.ITEM_TABLE_FILE + \ query = "CREATE TABLE IF NOT EXISTS " + self.ITEM_TABLE_FILE + \
"(id INTEGER PRIMARY KEY AUTOINCREMENT," + \ "(id INTEGER PRIMARY KEY AUTOINCREMENT," + \
"name CHAR(32) NOT NULL," + \ "name CHAR(32) NOT NULL," + \
"date CHAR(4)," + \ "date CHAR(4)," + \
"UNIQUE(date, name))" "UNIQUE(date, name))"
crs.execute(query) crs.execute(query)
query = "CREATE TABLE IF NOT EXISTS " + self.ENTRY_TABLE_FILE + \ query = "CREATE TABLE IF NOT EXISTS " + self.ENTRY_TABLE_FILE + \
"(id INTEGER PRIMARY KEY AUTOINCREMENT," + \ "(id INTEGER PRIMARY KEY AUTOINCREMENT," + \
"item_id INTEGER NOT NULL REFERENCES " + self.ITEM_TABLE_FILE + "(id)," + \ "item_id INTEGER NOT NULL REFERENCES " + self.ITEM_TABLE_FILE + "(id)," + \
"text TEXT NOT NULL," + \ "text TEXT NOT NULL," + \
"rating INTEGER NOT NULL," +\ "rating INTEGER NOT NULL," +\
"user_id INTEGER REFERENCES " + self.USER_TABLE_FILE + "(id),"\ "user_id INTEGER REFERENCES " + self.USER_TABLE_FILE + "(id),"\
"date CHAR(10) NOT NULL)" "date CHAR(10) NOT NULL)"
crs.execute(query) crs.execute(query)
db.commit() db.commit()
def insert_user(self, username, password): def insert_user(self, username, password):
""" """
Insert a row in the user table. Insert a row in the user table.
Parameters: Parameters:
@@ -212,20 +213,20 @@ class Database:
Returns: Returns:
int: number of the line the row was added, None if it wasn't successful int: number of the line the row was added, None if it wasn't successful
""" """
pass_hash = generate_password_hash(password) pass_hash = generate_password_hash(password)
if self.get_user_by_name(username) is None and pass_hash is not None: if self.get_user_by_name(username) is None and pass_hash is not None:
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "INSERT INTO " + self.USER_TABLE_FILE + \ query = "INSERT INTO " + self.USER_TABLE_FILE + \
"(`name`,`password`)" + \ "(`name`,`password`)" + \
"VALUES (?, ?) ON CONFLICT DO NOTHING" "VALUES (?, ?) ON CONFLICT DO NOTHING"
crs.execute(query, (username, pass_hash)) crs.execute(query, (username, pass_hash))
db.commit() db.commit()
return crs.lastrowid return crs.lastrowid
return None return None
def insert_entry(self, name, date, text, rating, user_id=None): def insert_entry(self, name, date, text, rating, user_id=None):
""" """
Insert a row in the entry table. Insert a row in the entry table.
Parameters: Parameters:
@@ -238,25 +239,25 @@ class Database:
Returns: Returns:
int: number of the line the row was added int: number of the line the row was added
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "INSERT OR IGNORE INTO " + self.ITEM_TABLE_FILE + \ query = "INSERT OR IGNORE INTO " + self.ITEM_TABLE_FILE + \
"(`name`,`date`)" + "VALUES (?, ?)" "(`name`,`date`)" + "VALUES (?, ?)"
crs.execute(query, (name, date)) crs.execute(query, (name, date))
query = "SELECT id FROM " + self.ITEM_TABLE_FILE + \ query = "SELECT id FROM " + self.ITEM_TABLE_FILE + \
" WHERE name = ? AND date = ?" " WHERE name = ? AND date = ?"
crs.execute(query, (name, date)) crs.execute(query, (name, date))
item_id = crs.fetchone()[0] item_id = crs.fetchone()[0]
date = dt.today().strftime('%Y-%m-%d') date = dt.today().strftime('%Y-%m-%d')
query = "INSERT INTO " + self.ENTRY_TABLE_FILE + \ query = "INSERT INTO " + self.ENTRY_TABLE_FILE + \
"(`item_id`, `text`, `rating`, `user_id`, `date`)" + \ "(`item_id`, `text`, `rating`, `user_id`, `date`)" + \
"VALUES (?, ?, ?, ?, ?)" "VALUES (?, ?, ?, ?, ?)"
crs.execute(query, (item_id, text, rating, user_id, date)) crs.execute(query, (item_id, text, rating, user_id, date))
db.commit() db.commit()
return crs.lastrowid return crs.lastrowid
def delete_entry(self, ident): def delete_entry(self, ident):
""" """
Delete a row from the entry table based on the entrys id. Delete a row from the entry table based on the entrys id.
Parameters: Parameters:
@@ -265,31 +266,69 @@ class Database:
Returns: Returns:
int: number of the line the row was removed from int: number of the line the row was removed from
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "DELETE FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?" query = "DELETE FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?"
crs.execute(query, (ident, )) crs.execute(query, (ident,))
db.commit() db.commit()
return crs.lastrowid return crs.lastrowid
def get_entries(self): def update_entry(self, ident, name, item_date, text, rating):
"""
Update an existing entry.
Parameters:
ident (int): id of the entry
name (str): updated item name
item_date (str): updated item date
text (str): updated review text
rating (int): updated rating
""" """
db = self.connect()
crs = db.cursor()
query = "SELECT item_id FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?"
crs.execute(query, (ident,))
fetched = crs.fetchone()
if fetched is None:
return None
item_id = fetched[0]
query = "UPDATE " + self.ITEM_TABLE_FILE + \
" SET name = ?, date = ? WHERE id = ?"
crs.execute(query, (name, item_date, item_id))
query = "UPDATE " + self.ENTRY_TABLE_FILE + \
" SET text = ?, rating = ? WHERE id = ?"
crs.execute(query, (text, rating, ident))
db.commit()
return ident
def get_entries(self):
"""
Return all the entries stored in the database. Return all the entries stored in the database.
Return: Return:
List(Entry): list of entries in database List(Entry): list of entries in database
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "SELECT * FROM " + self.ENTRY_TABLE_FILE query = "SELECT * FROM " + self.ENTRY_TABLE_FILE
crs.execute(query) crs.execute(query)
res = [] res = []
for item in crs.fetchall(): for item in crs.fetchall():
res.append(self.entry_from_db(*item)) res.append(self.entry_from_db(*item))
return res return res
def get_entry_by_id(self, ident): def get_entry_by_id(self, ident):
""" """
Return an entry stored in the database based on the entrys id. Return an entry stored in the database based on the entrys id.
Parameters: Parameters:
@@ -298,18 +337,18 @@ class Database:
Returns: Returns:
Entry: entry that matched the given id Entry: entry that matched the given id
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "SELECT * FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?" query = "SELECT * FROM " + self.ENTRY_TABLE_FILE + " WHERE id = ?"
crs.execute(query, (ident, )) crs.execute(query, (ident,))
fetched = crs.fetchone() fetched = crs.fetchone()
if fetched is None: if fetched is None:
return None return None
else: else:
return self.entry_from_db(*fetched) return self.entry_from_db(*fetched)
def get_entries_by_username(self, username): def get_entries_by_username(self, username):
""" """
Return a entries stored in the database based on the entries name. Return a entries stored in the database based on the entries name.
Parameters: Parameters:
@@ -318,19 +357,19 @@ class Database:
Returns: Returns:
List(Entry): entries that matched the given name List(Entry): entries that matched the given name
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "SELECT * FROM " + self.ENTRY_TABLE_FILE + \ query = "SELECT * FROM " + self.ENTRY_TABLE_FILE + \
" WHERE user_id = (SELECT id FROM " + self.USER_TABLE_FILE + \ " WHERE user_id = (SELECT id FROM " + self.USER_TABLE_FILE + \
" WHERE name = ?)" " WHERE name = ?)"
crs.execute(query, (username, )) crs.execute(query, (username,))
res = [] res = []
for item in crs.fetchall(): for item in crs.fetchall():
res.append(self.entry_from_db(*item)) res.append(self.entry_from_db(*item))
return res return res
def get_item_by_id(self, ident): def get_item_by_id(self, ident):
""" """
Return an item stored in the database based on the items id. Return an item stored in the database based on the items id.
Parameters: Parameters:
@@ -339,18 +378,18 @@ class Database:
Returns: Returns:
Item: item that matched the given id Item: item that matched the given id
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "SELECT * FROM " + self.ITEM_TABLE_FILE + " WHERE id = ?" query = "SELECT * FROM " + self.ITEM_TABLE_FILE + " WHERE id = ?"
crs.execute(query, (ident, )) crs.execute(query, (ident,))
fetched = crs.fetchone() fetched = crs.fetchone()
if fetched is None: if fetched is None:
return None return None
else: else:
return self.item_from_db(*fetched) return self.item_from_db(*fetched)
def get_user_by_id(self, ident): def get_user_by_id(self, ident):
""" """
Return a user stored in the database based on the users id. Return a user stored in the database based on the users id.
Parameters: Parameters:
@@ -359,18 +398,18 @@ class Database:
Returns: Returns:
Item: user that matched the given id Item: user that matched the given id
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "SELECT * FROM " + self.USER_TABLE_FILE + " WHERE id = ?" query = "SELECT * FROM " + self.USER_TABLE_FILE + " WHERE id = ?"
crs.execute(query, (ident, )) crs.execute(query, (ident,))
fetched = crs.fetchone() fetched = crs.fetchone()
if fetched is None: if fetched is None:
return None return None
else: else:
return self.user_from_db(*fetched) return self.user_from_db(*fetched)
def get_user_by_name(self, name): def get_user_by_name(self, name):
""" """
Return a user stored in the database based on the user name. Return a user stored in the database based on the user name.
Parameters: Parameters:
@@ -379,18 +418,18 @@ class Database:
Returns: Returns:
Entry: user that matched the given name Entry: user that matched the given name
""" """
db = self.connect() db = self.connect()
crs = db.cursor() crs = db.cursor()
query = "SELECT * FROM " + self.USER_TABLE_FILE + " WHERE name = ?" query = "SELECT * FROM " + self.USER_TABLE_FILE + " WHERE name = ?"
crs.execute(query, (name, )) crs.execute(query, (name,))
fetched = crs.fetchone() fetched = crs.fetchone()
if fetched is None: if fetched is None:
return None return None
else: else:
return self.user_from_db(*fetched) return self.user_from_db(*fetched)
def user_from_db(self, ident, name, pass_hash): def user_from_db(self, ident, name, pass_hash):
""" """
Return a user from given database parameters. Return a user from given database parameters.
Parameters: Parameters:
@@ -401,12 +440,12 @@ class Database:
Returns: Returns:
User: user element with given variables User: user element with given variables
""" """
user = User(name, pass_hash) user = User(name, pass_hash)
user.set_id(ident) user.set_id(ident)
return user return user
def item_from_db(self, ident, name, date): def item_from_db(self, ident, name, date):
""" """
Return an item from given database parameters. Return an item from given database parameters.
Parameters: Parameters:
@@ -417,12 +456,12 @@ class Database:
Returns: Returns:
Item: entry element with given variables Item: entry element with given variables
""" """
item = Item(name, date) item = Item(name, date)
item.set_id(ident) item.set_id(ident)
return item return item
def entry_from_db(self, ident, item_id, text, rating, user_id, date): def entry_from_db(self, ident, item_id, text, rating, user_id, date):
""" """
Return an entry from given database parameters. Return an entry from given database parameters.
Parameters: Parameters:
@@ -436,8 +475,8 @@ class Database:
Returns: Returns:
Entry: entry element with given variables Entry: entry element with given variables
""" """
entry = Entry(text, rating, date) entry = Entry(text, rating, date)
entry.set_id(ident) entry.set_id(ident)
entry.set_item(self.get_item_by_id(item_id)) entry.set_item(self.get_item_by_id(item_id))
entry.set_user(self.get_user_by_id(user_id)) entry.set_user(self.get_user_by_id(user_id))
return entry return entry

View File

@@ -1,61 +1,64 @@
from datetime import date from datetime import date
from flask_ckeditor import CKEditorField from flask_ckeditor import CKEditorField
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, StringField from wtforms import PasswordField, StringField, SubmitField
from wtforms.fields import IntegerField from wtforms.fields import IntegerField
from wtforms.validators import DataRequired, EqualTo, InputRequired, \ from wtforms.validators import (DataRequired, EqualTo, InputRequired, Length, NumberRange,
NumberRange, ValidationError, Length ValidationError)
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
""" """
A Class for the Form that is used while logging in. A Class for the Form that is used while logging in.
""" """
username = StringField("Username", validators=[DataRequired(), username = StringField("Username", validators=[DataRequired(), Length(min=4, max=32)])
Length(min=4, max=32)]) password = PasswordField("Password", validators=[DataRequired(), Length(min=4, max=32)])
password = PasswordField("Password", validators=[DataRequired(), submit = SubmitField("Sign In")
Length(min=4, max=32)])
submit = SubmitField("Sign In")
class RegisterForm(FlaskForm): class RegisterForm(FlaskForm):
""" """
A Class for the Form that is used while registering. A Class for the Form that is used while registering.
""" """
username = StringField("Username", validators=[DataRequired(), username = StringField("Username", validators=[DataRequired(), Length(min=4, max=32)])
Length(min=4, max=32)]) password = PasswordField("Password", validators=[DataRequired(), Length(min=4, max=32)])
password = PasswordField("Password", validators=[DataRequired(), password2 = PasswordField("Repeat Password", validators=[DataRequired(), EqualTo("password")])
Length(min=4, max=32)]) submit = SubmitField("Register")
password2 = PasswordField(
"Repeat Password", validators=[DataRequired(), EqualTo("password")])
submit = SubmitField("Register")
class SearchForm(FlaskForm): class SearchForm(FlaskForm):
""" """
A Class for the Form that is used while searching. A Class for the Form that is used while searching.
""" """
query_str = StringField( query_str = StringField("Query", [DataRequired("Please enter the search term")])
"Query", [DataRequired("Please enter the search term")]) submit = SubmitField("Search")
submit = SubmitField("Search")
class WriteForm(FlaskForm): class WriteForm(FlaskForm):
""" """
A Class for the Form that is used while writing a new entry. A Class for the Form that is used while writing a new entry.
""" """
name = StringField("Name", validators=[DataRequired(), name = StringField("Name", validators=[DataRequired(), Length(min=2, max=64)])
Length(min=2, max=64)]) date = IntegerField("Release Year",
date = IntegerField("Release Year", default=date.today().year, validators=[ default=date.today().year,
DataRequired(), NumberRange(min=0, max=date.today().year, validators=[
message="Year has to be valid.")]) DataRequired(),
text = CKEditorField("Text", validators=[DataRequired()]) NumberRange(min=0, max=date.today().year, message="Year has to be valid.")
rating = IntegerField("Rating", default=50, validators=[InputRequired( ])
), NumberRange(min=0, max=100, message="Number has to be between 0 and 100.")]) text = CKEditorField("Text", validators=[DataRequired()])
submit = SubmitField("Publish") rating = IntegerField("Rating",
default=50,
validators=[
InputRequired(),
NumberRange(min=0,
max=100,
message="Number has to be between 0 and 100.")
])
submit = SubmitField("Publish")
def validate_text(self, text): def validate_text(self, text):
""" """
Validate a given input for html level one headers. Validate a given input for html level one headers.
Parameters: Parameters:
@@ -67,5 +70,5 @@ class WriteForm(FlaskForm):
Raises: Raises:
ValidatenError: if the text contains a first level html tag ValidatenError: if the text contains a first level html tag
""" """
if "<h1>" in text.data or "</h1>" in text.data: if "<h1>" in text.data or "</h1>" in text.data:
raise ValidationError("Headings on level 1 are not permitted.") raise ValidationError("Headings on level 1 are not permitted.")

View File

@@ -1,7 +1,14 @@
Flask bleach==6.3.0
Flask_CKEditor blinker==1.9.0
Flask_Login click==8.3.3
Flask_WTF flask==3.1.3
Werkzeug flask-ckeditor==1.0.0
Whoosh flask-login==0.6.3
WTForms flask-wtf==1.3.0
itsdangerous==2.2.0
jinja2==3.1.6
markupsafe==3.0.3
webencodings==0.5.1
werkzeug==3.1.8
whoosh==2.7.4
wtforms==3.2.2

View File

@@ -2,8 +2,8 @@ import os
import re import re
from whoosh import scoring from whoosh import scoring
from whoosh.fields import ID, TEXT, Schema
from whoosh.index import create_in, open_dir from whoosh.index import create_in, open_dir
from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser from whoosh.qparser import QueryParser
import config import config
@@ -13,7 +13,7 @@ CLEANR = re.compile('<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});')
def remove_html_tags(text): def remove_html_tags(text):
""" """
Convert a text from html formatted to unformatted. Convert a text from html formatted to unformatted.
Parameters: Parameters:
@@ -22,31 +22,30 @@ def remove_html_tags(text):
Returns: Returns:
str: text without html tags str: text without html tags
""" """
res = re.sub(CLEANR, '', text) res = re.sub(CLEANR, '', text)
return res return res
def create_search_index(): def create_search_index():
""" """
Create the index data to search all entries. Create the index data to search all entries.
""" """
db = Database() db = Database()
schema = Schema(title=TEXT(stored=True), schema = Schema(title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True))
path=ID(stored=True), content=TEXT(stored=True)) if not os.path.exists(config.INDEX_DIR):
if not os.path.exists(config.INDEX_DIR): os.mkdir(config.INDEX_DIR)
os.mkdir(config.INDEX_DIR) ix = create_in(config.INDEX_DIR, schema)
ix = create_in(config.INDEX_DIR, schema) writer = ix.writer()
writer = ix.writer() for entry in db.get_entries():
for entry in db.get_entries(): path = str(entry.id)
path = str(entry.id) text = entry.item.name + " " + entry.item.date + " " + entry.text + \
text = entry.item.name + " " + entry.item.date + " " + entry.text + \ " by " + entry.user.name + " " + entry.date
" by " + entry.user.name + " " + entry.date writer.add_document(title=entry.item.name, path=path, content=text)
writer.add_document(title=entry.item.name, path=path, content=text) writer.commit()
writer.commit()
def ft_search_times(query_str, number): def ft_search_times(query_str, number):
""" """
Search for a given term and returns a specific amount of results. Search for a given term and returns a specific amount of results.
Parameters: Parameters:
@@ -56,19 +55,19 @@ def ft_search_times(query_str, number):
Returns: Returns:
List(Entry): list of entries that matched the search List(Entry): list of entries that matched the search
""" """
ix = open_dir(config.INDEX_DIR) ix = open_dir(config.INDEX_DIR)
results = [] results = []
db = Database() db = Database()
with ix.searcher(weighting=scoring.BM25F) as s: with ix.searcher(weighting=scoring.BM25F) as s:
query = QueryParser("content", ix.schema).parse(query_str) query = QueryParser("content", ix.schema).parse(query_str)
matches = s.search(query, limit=number) matches = s.search(query, limit=number)
for match in matches: for match in matches:
results.append(db.get_entry_by_id(match["path"])) results.append(db.get_entry_by_id(match["path"]))
return results return results
def ft_search(query_str): def ft_search(query_str):
""" """
Search for a given term and show the predefined amount of results. Search for a given term and show the predefined amount of results.
Parameters: Parameters:
@@ -77,4 +76,4 @@ def ft_search(query_str):
Returns: Returns:
List(Entry): list of entries that matched the search List(Entry): list of entries that matched the search
""" """
return ft_search_times(query_str, config.SEARCH_NUMBER) return ft_search_times(query_str, config.SEARCH_NUMBER)

View File

@@ -14,12 +14,14 @@
--text1: rgb(220, 120, 0); --text1: rgb(220, 120, 0);
} }
a { a,
.link-like {
color: var(--link0); color: var(--link0);
transition: var(--transtime); transition: var(--transtime);
} }
a:hover { a:hover,
.link-like:hover {
color: var(--link1); color: var(--link1);
} }

View File

@@ -14,12 +14,14 @@
--text1: rgb(0,0,120); --text1: rgb(0,0,120);
} }
a { a,
.link-like {
color: var(--link0); color: var(--link0);
transition: var(--transtime); transition: var(--transtime);
} }
a:hover { a:hover,
.link-like:hover {
color: var(--link1); color: var(--link1);
} }

View File

@@ -8,15 +8,26 @@
padding: 0; padding: 0;
} }
a { a,
.link-like {
text-decoration: none; text-decoration: none;
transition: var(--transtime); transition: var(--transtime);
} }
a:hover { a:hover,
.link-like:hover {
cursor: pointer; cursor: pointer;
} }
.link-like {
background: none;
border: none;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
}
body { body {
margin: 0; margin: 0;
} }

View File

@@ -1,43 +1,43 @@
{% extends "template.html" -%} {% extends "template.html" -%}
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="archive"> <div class="archive">
<h1>Archive</h1><br> <h1>Archive</h1><br>
{% set ns = namespace(prev_item_date="", prev_item_id=None, open_li = False, open_ul = False) -%} {% set ns = namespace(prev_item_date="", prev_item_id=None, open_li = False, open_ul = False) -%}
{% for entry in entries -%} {% for entry in entries -%}
{% if ns.prev_item_id != entry.item.id and ns.prev_item_id != None -%} {% if ns.prev_item_id != entry.item.id and ns.prev_item_id != None -%}
</li> </li>
{% set ns.open_li = False -%} {% set ns.open_li = False -%}
{% endif -%} {% endif -%}
{% if entry.item.date != ns.prev_item_date -%} {% if entry.item.date != ns.prev_item_date -%}
{% if ns.prev_item_date != "" -%} {% if ns.prev_item_date != "" -%}
</ul> </ul>
{% set ns.open_ul = False -%} {% set ns.open_ul = False -%}
{% endif -%} {% endif -%}
<h2> {{ entry.item.date }} </h2> <h2> {{ entry.item.date }} </h2>
<ul> <ul>
{% set ns.open_ul = True -%} {% set ns.open_ul = True -%}
{% endif -%} {% endif -%}
{% if ns.prev_item_id == entry.item.id -%} {% if ns.prev_item_id == entry.item.id -%}
<br> <br>
{% else -%} {% else -%}
<li> <li>
{{ entry.item.name }}<br> {{ entry.item.name }}<br>
{% set ns.open_li = True -%} {% set ns.open_li = True -%}
{% endif -%} {% endif -%}
<a href="{{ url_for('entry', ident=entry.id) }}"> <a href="{{ url_for('entry', ident=entry.id) }}">
{{ entry.date }} {{ r_to_star(entry.rating) }} by {{ entry.user.name }} {{ entry.date }} {{ r_to_star(entry.rating) }} by {{ entry.user.name }}
</a> </a>
{% set ns.prev_item_date = entry.item.date -%} {% set ns.prev_item_date = entry.item.date -%}
{% set ns.prev_item_id = entry.item.id -%} {% set ns.prev_item_id = entry.item.id -%}
{% endfor -%} {% endfor -%}
{% if ns.open_li -%} {% if ns.open_li -%}
</li> </li>
{% endif -%} {% endif -%}
{% if ns.open_ul -%} {% if ns.open_ul -%}
</ul> </ul>
{% endif -%} {% endif -%}
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,10 +1,10 @@
{% extends "template.html" -%} {% extends "template.html" -%}
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="important"> <div class="important">
Error<br> Error<br>
<span>{{ errorcode }}</span> <span>{{ errorcode }}</span>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,27 +1,27 @@
{% extends "template.html" -%} {% extends "template.html" -%}
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="blog"> <div class="blog">
<h1>Blog</h1><br> <h1>Blog</h1><br>
{% for entry in entries -%} {% for entry in entries -%}
<div class="entry"> <div class="entry">
<h1 id="{{ entry.id }}"> <h1 id="{{ entry.id }}">
<a href="{{ url_for('entry', ident=entry.id) }}"> <a href="{{ url_for('entry', ident=entry.id) }}">
{{ entry.item.name }} ({{ entry.item.date }}) {{ r_to_star(entry.rating) }} {{ entry.item.name }} ({{ entry.item.date }}) {{ r_to_star(entry.rating) }}
</a> </a>
</h1> </h1>
<small> <small>
rated {{ entry.rating }}/100 by rated {{ entry.rating }}/100 by
<a href="{{ url_for('user', name=entry.user.name) }}"> <a href="{{ url_for('user', name=entry.user.name) }}">
{{ entry.user.name }} {{ entry.user.name }}
</a> on {{ entry.date }} </a> on {{ entry.date }}
</small><br> </small><br>
{% autoescape off -%} {% autoescape off -%}
{{ entry.text }} {{ entry.text }}
{% endautoescape -%} {% endautoescape -%}
</div><br> </div><br>
{% endfor -%} {% endfor -%}
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,24 +1,24 @@
{% extends "template.html" -%} {% extends "template.html" -%}
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="logging"> <div class="logging">
<h1>Sign In</h1> <h1>Sign In</h1>
<form action="" method="post" novalidate> <form action="" method="post" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> <p>
{{ form.username.label }}<br> {{ form.username.label }}<br>
{{ form.username(size=32) }} {{ form.username(size=32) }}
</p> </p>
<p> <p>
{{ form.password.label }}<br> {{ form.password.label }}<br>
{{ form.password(size=32) }} {{ form.password(size=32) }}
</p> </p>
<p>{{ form.submit() }}</p> <p>{{ form.submit() }}</p>
{% for mesg in get_flashed_messages() -%} {% for mesg in get_flashed_messages() -%}
<p>{{ mesg }}</p> <p>{{ mesg }}</p>
{% endfor -%} {% endfor -%}
</form> </form>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,37 +1,37 @@
{% extends "template.html" -%} {% extends "template.html" -%}
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="logging"> <div class="logging">
<h1>Register</h1> <h1>Register</h1>
<form action="" method="post" novalidate> <form action="" method="post" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> <p>
{{ form.username.label }}<br> {{ form.username.label }}<br>
{{ form.username(size=32) }} {{ form.username(size=32) }}
{% for error in form.username.errors -%} {% for error in form.username.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
</p> </p>
<p> <p>
{{ form.password.label }}<br> {{ form.password.label }}<br>
{{ form.password(size=32) }} {{ form.password(size=32) }}
{% for error in form.password.errors -%} {% for error in form.password.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
</p> </p>
<p> <p>
{{ form.password2.label }}<br> {{ form.password2.label }}<br>
{{ form.password2(size=32) }} {{ form.password2(size=32) }}
{% for error in form.password2.errors -%} {% for error in form.password2.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
</p> </p>
<p>{{ form.submit() }}</p> <p>{{ form.submit() }}</p>
{% for mesg in get_flashed_messages() -%} {% for mesg in get_flashed_messages() -%}
<p>{{ mesg }}</p> <p>{{ mesg }}</p>
{% endfor -%} {% endfor -%}
</form> </form>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,29 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel> <channel>
<title>{{ title }}</title> <title>{{ title }}</title>
<description>{{ description }}</description> <description>{{ description }}</description>
<language>en-us</language> <language>en-us</language>
<link>{{ url_for("feed", _external=True) }}</link> <link>{{
<atom:link href="{{ url_for('feed', _external=True) }}" rel="self" type="application/rss+xml" /> url_for("feed", _external=True) }}</link>
{% for entry in entries -%} <atom:link
<item> href="{{ url_for('feed', _external=True) }}" rel="self" type="application/rss+xml" /> {%
<title> for entry in entries -%} <item>
{{ entry.item.name }} ({{ entry.item.date }}) {{ r_to_star(entry.rating) }} by {{ entry.user.name }} <title>
</title> {{ entry.item.name }} ({{ entry.item.date }}) {{ r_to_star(entry.rating) }} by {{
<guid> entry.user.name }}
{{ url_for("index", _anchor=entry.id, _external=True) }} </title>
</guid> <guid>
<pubDate> {{ url_for("index", _anchor=entry.id, _external=True) }}
{{ entry.date }} </guid>
</pubDate> <pubDate>
<description> {{ entry.date }}
{% autoescape off -%} </pubDate>
text <description>
{% endautoescape -%} {% autoescape off -%}
</description> text
</item> {% endautoescape -%}
{% endfor -%} </description>
</channel> </item>
{% endfor -%} </channel>
</rss> </rss>

View File

@@ -2,22 +2,23 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<h1>Search</h1><br> <h1>Search</h1><br>
<div class="search"> <div class="search">
<form action="{{ url_for('search') }}" method=post> <form action="{{ url_for('search') }}" method=post>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.query_str }} {{ form.query_str }}
{{ form.submit }} {{ form.submit }}
</form> </form>
<ul> <ul>
{% for entry in results -%} {% for entry in results -%}
<li> <li>
<a href="{{ url_for('entry', ident=entry.id) }}"> <a href="{{ url_for('entry', ident=entry.id) }}">
{{ entry.date }} {{ r_to_star(entry.rating) }} {{ entry.item.name }} ({{ entry.item.date }}) by {{ entry.user.name }} {{ entry.date }} {{ r_to_star(entry.rating) }} {{ entry.item.name }} ({{ entry.item.date }}) by {{
</a> entry.user.name }}
</li> </a>
{% endfor -%} </li>
</ul> {% endfor -%}
</div> </ul>
</div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,34 +1,39 @@
{% extends "template.html" -%} {% extends "template.html" -%}
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="standalone"> <div class="standalone">
<h1> <h1>
{{ entry.item.name }} ({{ entry.item.date }}) {{ entry.item.name }} ({{ entry.item.date }})
{{ r_to_star(entry.rating) }} {{ r_to_star(entry.rating) }}
</h1> </h1>
<small> <small>
rated {{ entry.rating }}/100 by rated {{ entry.rating }}/100 by
<a href="{{ url_for('user', name=entry.user.name) }}"> <a href="{{ url_for('user', name=entry.user.name) }}">
{{ entry.user.name }} {{ entry.user.name }}
</a> </a>
on on
<a href="{{ url_for('index', _anchor='{0:d}'.format(entry.id)) }}"> <a href="{{ url_for('index', _anchor='{0:d}'.format(entry.id)) }}">
{{ entry.date }} {{ entry.date }}
</a> </a>
</small><br> </small><br>
{% if current_user.id == entry.user.id -%} {% if current_user.id == entry.user.id -%}
<small> <a class="link-like" href="{{ url_for('edit_entry', ident='{0:d}'.format(entry.id)) }}">
[ edit entry
<a href="{{ url_for('delete_entry', ident='{0:d}'.format(entry.id)) }}"> </a>
delete entry -
</a> <form method="post" action="{{ url_for('delete_entry', ident='{0:d}'.format(entry.id)) }}" style="display:inline;"
] onsubmit="return confirm('Delete entry?');">
</small><br> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% endif -%}
{% autoescape off -%} <button type="submit" class="link-like">
{{ entry.text }} delete entry
{% endautoescape -%}<br> </button>
</div> </form>
</div> {% endif -%}
{% autoescape off -%}
{{ entry.text }}
{% endautoescape -%}<br>
</div>
</div>
{% endblock -%} {% endblock -%}

View File

@@ -1,48 +1,51 @@
{% set navigation_bar = [ {% set navigation_bar = [
(url_for("index"), "index", "Blog"), (url_for("index"), "index", "Blog"),
(url_for("archive"), "archive", "Archive"), (url_for("archive"), "archive", "Archive"),
(url_for("search"), "search", "Search") (url_for("search"), "search", "Search")
] -%} ] -%}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>{{ title }}</title> <title>{{ title }}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width" initial-scale=1.0> <meta name="viewport" content="width=device-width" initial-scale=1.0>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='graphics/logo.png') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='graphics/logo.png') }}">
<link href="{{ url_for('static', filename='css/' + style + '.css') }}" rel="stylesheet" type="text/css"> <link href="{{ url_for('static', filename='css/' + style + '.css') }}" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<div class="main-menu-dropdown"> <div class="main-menu-dropdown">
<!-- <img class="logo" src="/static/images/logo.png"> --> <!-- <img class="logo" src="/static/images/logo.png"> -->
<a href="{{ url_for('index') }}"> <a href="{{ url_for('index') }}">
<img class="logo" src="{{ url_for('static', filename='graphics/logo.png') }}"> <img class="logo" src="{{ url_for('static', filename='graphics/logo.png') }}">
<span>{{ title }}</span> <span>{{ title }}</span>
</a> </a>
<input type="checkbox" id="main-menu-check"> <input type="checkbox" id="main-menu-check">
<label for="main-menu-check" class="show-menu">&#9776;</label> <label for="main-menu-check" class="show-menu">&#9776;</label>
<div class="main-menu"> <div class="main-menu">
{% for href, id, caption in navigation_bar -%} {% for href, id, caption in navigation_bar -%}
<a href="{{ href|e }}">{{ caption|e }}</a> <a href="{{ href|e }}">{{ caption|e }}</a>
{% endfor -%} {% endfor -%}
<label for="main-menu-check" class="hide-menu">X</label> <label for="main-menu-check" class="hide-menu">X</label>
</div>
</div> </div>
{% block content -%} </div>
{% endblock -%} {% block content -%}
<footer> {% endblock -%}
Made with <a href="https://github.com/tiyn/container-critique">Container Critique </a>.<br> <footer>
{% if current_user.is_anonymous -%} Made with <a href="https://github.com/tiyn/container-critique">Container Critique </a>.<br>
<a href="{{ url_for('login') }}">Login</a> {% if current_user.is_anonymous -%}
{% if registration -%} <a href="{{ url_for('login') }}">Login</a>
- {% if registration -%}
<a href="{{ url_for('register') }}">Register</a> -
{% endif -%} <a href="{{ url_for('register') }}">Register</a>
{% else -%} {% endif -%}
<a href="{{ url_for('logout') }}">Logout</a> - {% else -%}
<a href="{{ url_for('write_entry') }}">Write entry</a> <a href="{{ url_for('logout') }}">Logout</a> -
{% endif -%} <a href="{{ url_for('write_entry') }}">Write entry</a>
</footer> {% endif -%}
</footer>
</body> </body>
</html> </html>

View File

@@ -2,42 +2,42 @@
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="archive"> <div class="archive">
<h1>User: {{ name }}</h1><br> <h1>User: {{ name }}</h1><br>
{% set ns = namespace(prev_item_date="", prev_item_id=None, open_li = False, open_ul = False) -%} {% set ns = namespace(prev_item_date="", prev_item_id=None, open_li = False, open_ul = False) -%}
{% for entry in entries -%} {% for entry in entries -%}
{% if ns.prev_item_id != entry.item.id and ns.prev_item_id != None -%} {% if ns.prev_item_id != entry.item.id and ns.prev_item_id != None -%}
</li> </li>
{% set ns.open_li = False -%} {% set ns.open_li = False -%}
{% endif -%} {% endif -%}
{% if entry.item.date != ns.prev_item_date -%} {% if entry.item.date != ns.prev_item_date -%}
{% if ns.prev_item_date != "" -%} {% if ns.prev_item_date != "" -%}
</ul> </ul>
{% set ns.open_ul = False -%} {% set ns.open_ul = False -%}
{% endif -%} {% endif -%}
<h2> {{ entry.item.date }} </h2> <h2> {{ entry.item.date }} </h2>
<ul> <ul>
{% set ns.open_ul = True -%} {% set ns.open_ul = True -%}
{% endif -%} {% endif -%}
{% if ns.prev_item_id == entry.item.id -%} {% if ns.prev_item_id == entry.item.id -%}
<br> <br>
{% else -%} {% else -%}
<li> <li>
{{ entry.item.name }}<br> {{ entry.item.name }}<br>
{% set ns.open_li = True -%} {% set ns.open_li = True -%}
{% endif -%} {% endif -%}
<a href="{{ url_for('entry', ident=entry.id) }}"> <a href="{{ url_for('entry', ident=entry.id) }}">
{{ entry.date }} {{ r_to_star(entry.rating) }} {{ entry.date }} {{ r_to_star(entry.rating) }}
</a> </a>
{% set ns.prev_item_date = entry.item.date -%} {% set ns.prev_item_date = entry.item.date -%}
{% set ns.prev_item_id = entry.item.id -%} {% set ns.prev_item_id = entry.item.id -%}
{% endfor -%} {% endfor -%}
{% if ns.open_li -%} {% if ns.open_li -%}
</li> </li>
{% endif -%} {% endif -%}
{% if ns.open_ul -%} {% if ns.open_ul -%}
</ul> </ul>
{% endif -%} {% endif -%}
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -2,46 +2,46 @@
{% block content -%} {% block content -%}
<div class="container"> <div class="container">
<div class="writing"> <div class="writing">
<h1>Write Critique</h1> <h1>Write Critique</h1>
<form action="" method="post" novalidate> <form action="" method="post" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> <p>
{{ form.name.label }}<br> {{ form.name.label }}<br>
{{ form.name(size=64) }} {{ form.name(size=64) }}
{% for error in form.name.errors -%} {% for error in form.name.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
</p> </p>
<p> <p>
{{ form.date.label }}<br> {{ form.date.label }}<br>
{{ form.date }} {{ form.date }}
{% for error in form.date.errors -%} {% for error in form.date.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
</p> </p>
<p> <p>
{{ form.text.label }}<br> {{ form.text.label }}<br>
{{ form.text }} {{ form.text }}
{% for error in form.text.errors -%} {% for error in form.text.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
{{ ckeditor.load() }} {{ ckeditor.load() }}
{{ ckeditor.config(name="text") }} {{ ckeditor.config(name="text") }}
</p> </p>
<p> <p>
{{ form.rating.label }}<br> {{ form.rating.label }}<br>
{{ form.rating }} {{ form.rating }}
{% for error in form.rating.errors -%} {% for error in form.rating.errors -%}
<span style="color: red;">[{{ error }}]</span> <span style="color: red;">[{{ error }}]</span>
{% endfor -%} {% endfor -%}
</p> </p>
<p>{{ form.submit() }}</p> <p>{{ form.submit() }}</p>
{% for mesg in get_flashed_messages() -%} {% for mesg in get_flashed_messages() -%}
<p>{{ mesg }}</p> <p>{{ mesg }}</p>
{% endfor -%} {% endfor -%}
</form> </form>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

215
uv.lock generated Normal file
View File

@@ -0,0 +1,215 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "bleach"
version = "6.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "container-critique"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "bleach" },
{ name = "flask-ckeditor" },
{ name = "flask-login" },
{ name = "flask-wtf" },
{ name = "whoosh" },
]
[package.metadata]
requires-dist = [
{ name = "bleach", specifier = ">=6.3.0" },
{ name = "flask-ckeditor", specifier = ">=1.0.0" },
{ name = "flask-login", specifier = ">=0.6.3" },
{ name = "flask-wtf", specifier = ">=1.3.0" },
{ name = "whoosh", specifier = ">=2.7.4" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-ckeditor"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/c9/d7559169bfc76c29054e1a4a4f1c49c034c1ba931181e48bb6af7ed87e7a/flask_ckeditor-1.0.0.tar.gz", hash = "sha256:e1737ca180ea0d46d53226f888f4786589f2e8ed810694aaff2aa68dfad15a98", size = 3387711, upload-time = "2024-07-04T15:42:45.23Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/80/961516d05ffaf17c32aa595d30cdf7287a218d2ff60f212a09de512ffb19/Flask_CKEditor-1.0.0-py2.py3-none-any.whl", hash = "sha256:1a4aa871b510100df7bb8401b71cdcddfe1fd6d860649bc3760d0d43df485d72", size = 4581046, upload-time = "2024-07-04T15:42:42.851Z" },
]
[[package]]
name = "flask-login"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
]
[[package]]
name = "flask-wtf"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "itsdangerous" },
{ name = "wtforms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/f1/605a56d4ea217b307f3e6f4d663e0351253d85d841edc93ba559f0648e19/flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc", size = 50414, upload-time = "2026-04-23T07:41:55.096Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/d2/97adf2ec7af95522573e6dd5493ee84792d0fbfb2def010c4a581b8d6e5e/flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160", size = 13959, upload-time = "2026-04-23T07:41:53.828Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "webencodings"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
[[package]]
name = "whoosh"
version = "2.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/2b/6beed2107b148edc1321da0d489afc4617b9ed317ef7b72d4993cad9b684/Whoosh-2.7.4.tar.gz", hash = "sha256:7ca5633dbfa9e0e0fa400d3151a8a0c4bec53bd2ecedc0a67705b17565c31a83", size = 968741, upload-time = "2016-04-04T01:19:32.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/24d0f1f454a2c1eb689ca28d2f178db81e5024f42d82729a4ff6771155cf/Whoosh-2.7.4-py2.py3-none-any.whl", hash = "sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852", size = 468790, upload-time = "2016-04-04T01:19:40.379Z" },
]
[[package]]
name = "wtforms"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb747566aa3c12a734bd64ea7449a0d25ec74ce8f8b8eb/wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93", size = 139583, upload-time = "2026-05-03T05:53:44.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" },
]