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

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] Rich text editor
- [x] CSS for all tags - [x] User Interface
- [x] Navigation - [x] Responsive navigation
- [x] Header - [x] Header
- [x] Footer - [x] Footer
- [x] Switchable CSS - [x] Rich text editor
- [x] CSS dark-theme - [x] Star ratings
- [x] CSS light-theme - [x] Styling for common HTML tags
- [x] Docker installation - [x] Theme support
- [x] Logo - [x] Dark theme
- [x] Light theme
- [x] Logo
- [x] Deployment
- [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

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()
@@ -51,9 +49,11 @@ def inject_title():
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,
style=config.STYLE,
description=config.DESCRIPTION, description=config.DESCRIPTION,
registration=config.ALLOW_REGISTRATION, r_to_star=rating_to_star) registration=config.ALLOW_REGISTRATION,
r_to_star=rating_to_star)
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
@@ -239,14 +239,14 @@ def write_entry():
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):
""" """
@@ -263,5 +263,36 @@ def delete_entry(ident):
return redirect(url_for("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

@@ -13,9 +13,9 @@ 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))

View File

@@ -1,7 +1,8 @@
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():
@@ -268,10 +269,48 @@ class Database:
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 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): def get_entries(self):
""" """
Return all the entries stored in the database. Return all the entries stored in the database.
@@ -301,7 +340,7 @@ class Database:
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
@@ -323,7 +362,7 @@ class Database:
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))
@@ -342,7 +381,7 @@ class Database:
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
@@ -362,7 +401,7 @@ class Database:
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
@@ -382,7 +421,7 @@ class Database:
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

View File

@@ -1,20 +1,19 @@
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(),
Length(min=4, max=32)])
submit = SubmitField("Sign In") submit = SubmitField("Sign In")
@@ -22,12 +21,9 @@ 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)])
password2 = PasswordField(
"Repeat Password", validators=[DataRequired(), EqualTo("password")])
submit = SubmitField("Register") submit = SubmitField("Register")
@@ -35,8 +31,7 @@ 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")
@@ -44,14 +39,22 @@ 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(),
NumberRange(min=0, max=date.today().year, message="Year has to be valid.")
])
text = CKEditorField("Text", validators=[DataRequired()]) text = CKEditorField("Text", validators=[DataRequired()])
rating = IntegerField("Rating", default=50, validators=[InputRequired( rating = IntegerField("Rating",
), NumberRange(min=0, max=100, message="Number has to be between 0 and 100.")]) default=50,
validators=[
InputRequired(),
NumberRange(min=0,
max=100,
message="Number has to be between 0 and 100.")
])
submit = SubmitField("Publish") submit = SubmitField("Publish")
def validate_text(self, text): def validate_text(self, text):

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
@@ -31,8 +31,7 @@ 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)

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,7 +1,7 @@
{% 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) -%}
@@ -39,5 +39,5 @@
</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,7 +1,7 @@
{% 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 -%}
@@ -23,5 +23,5 @@
</div><br> </div><br>
{% endfor -%} {% endfor -%}
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,7 +1,7 @@
{% 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>
@@ -20,5 +20,5 @@
{% endfor -%} {% endfor -%}
</form> </form>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,7 +1,7 @@
{% 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>
@@ -33,5 +33,5 @@
{% endfor -%} {% endfor -%}
</form> </form>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,16 +1,18 @@
<?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" /> {%
for entry in entries -%} <item>
<title> <title>
{{ entry.item.name }} ({{ entry.item.date }}) {{ r_to_star(entry.rating) }} by {{ entry.user.name }} {{ entry.item.name }} ({{ entry.item.date }}) {{ r_to_star(entry.rating) }} by {{
entry.user.name }}
</title> </title>
<guid> <guid>
{{ url_for("index", _anchor=entry.id, _external=True) }} {{ url_for("index", _anchor=entry.id, _external=True) }}
@@ -24,6 +26,5 @@
{% endautoescape -%} {% endautoescape -%}
</description> </description>
</item> </item>
{% endfor -%} {% endfor -%} </channel>
</channel>
</rss> </rss>

View File

@@ -13,7 +13,8 @@
{% 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 {{
entry.user.name }}
</a> </a>
</li> </li>
{% endfor -%} {% endfor -%}

View File

@@ -1,7 +1,7 @@
{% 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 }})
@@ -18,17 +18,22 @@
</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)) }}">
delete entry
</a> </a>
] -
</small><br> <form method="post" action="{{ url_for('delete_entry', ident='{0:d}'.format(entry.id)) }}" style="display:inline;"
onsubmit="return confirm('Delete entry?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="link-like">
delete entry
</button>
</form>
{% endif -%} {% endif -%}
{% autoescape off -%} {% autoescape off -%}
{{ entry.text }} {{ entry.text }}
{% endautoescape -%}<br> {% endautoescape -%}<br>
</div> </div>
</div> </div>
{% endblock -%} {% endblock -%}

View File

@@ -1,11 +1,12 @@
{% 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">
@@ -13,6 +14,7 @@
<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"> -->
@@ -45,4 +47,5 @@
{% endif -%} {% endif -%}
</footer> </footer>
</body> </body>
</html> </html>

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" },
]