major: add receipt upload

You can now upload receipts and check which ones should be accounted.
Currently without function, needs to be implemented.
This commit is contained in:
Lunaresk 2023-08-06 22:25:08 +02:00
parent 5e4a59e15f
commit 6c0fa5a58b
24 changed files with 358 additions and 144 deletions

2
.gitignore vendored
View File

@ -339,6 +339,7 @@ pip-selfcheck.json
# Misc
config.yaml
scans.json
test_*.*
test.*
*.db
.vscode
@ -349,3 +350,4 @@ test.*
!.env.project
!.env.vault
*.ps1
*.pdf

View File

@ -5,14 +5,13 @@ RUN useradd costhive
WORKDIR /home/costhive
RUN apt update && apt -y upgrade; \
apt install -y libpq-dev gcc g++ swig make; \
apt install -y libpq-dev gcc g++ swig make cmake m4; \
rm -rf /var/lib/apt/lists
COPY boot.sh requirements.txt ./
COPY boot.sh backend/requirements.txt ./
RUN python -m venv venv; \
venv/bin/pip install --upgrade pip; \
venv/bin/pip install wheel; \
venv/bin/pip install gunicorn; \
venv/bin/pip install wheel gunicorn; \
venv/bin/pip install -r requirements.txt
COPY backend backend
@ -20,7 +19,7 @@ COPY backend backend
ENV FLASK_APP run.py
RUN chmod +x boot.sh; \
chown -R costhive:costhive ./
chown -R costhive:costhive .
USER costhive

View File

@ -21,6 +21,7 @@ python-dotenv = "*"
pymupdf = "*"
requests = "*"
sqlalchemy-utils = "*"
wtforms-sqlalchemy = "*"
[dev-packages]

88
backend/Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "6aa4fc89c41e2a25283179a13d468214fdebcbd824a74a7f2f79482b920eed78"
"sha256": "5418d6b50788a542461e9a348ad0864d3e4d2046e48967df081cbaed9971ed0f"
},
"pipfile-spec": 6,
"requires": {
@ -25,14 +25,6 @@
"markers": "python_version >= '3.7'",
"version": "==1.11.1"
},
"anyio": {
"hashes": [
"sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780",
"sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"
],
"markers": "python_version >= '3.7'",
"version": "==3.7.1"
},
"blinker": {
"hashes": [
"sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213",
@ -43,11 +35,11 @@
},
"certifi": {
"hashes": [
"sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7",
"sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"
"sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082",
"sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"
],
"markers": "python_version >= '3.6'",
"version": "==2023.5.7"
"version": "==2023.7.22"
},
"charset-normalizer": {
"hashes": [
@ -132,11 +124,11 @@
},
"click": {
"hashes": [
"sha256:4be4b1af8d665c6d942909916d31a213a106800c47d0eeba73d34da3cbc11367",
"sha256:e576aa487d679441d7d30abb87e1b43d24fc53bffb8758443b1a9e1cee504548"
"sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd",
"sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"
],
"markers": "python_version >= '3.7'",
"version": "==8.1.5"
"version": "==8.1.6"
},
"colorama": {
"hashes": [
@ -148,11 +140,11 @@
},
"dnspython": {
"hashes": [
"sha256:46b4052a55b56beea3a3bdd7b30295c292bd6827dd442348bc116f2d35b17f0a",
"sha256:758e691dbb454d5ccf4e1b154a19e52847f79e21a42fef17b969144af29a4e6c"
"sha256:5b7488477388b8c0b70a8ce93b227c5603bc7b77f1565afe8e729c36c51447d7",
"sha256:c33971c79af5be968bb897e95c2448e11a645ee84d93b265ce0b7aabe5dfdca8"
],
"markers": "python_version >= '3.8' and python_version < '4.0'",
"version": "==2.4.0"
"version": "==2.4.1"
},
"dominate": {
"hashes": [
@ -170,14 +162,6 @@
"index": "pypi",
"version": "==2.0.0.post2"
},
"exceptiongroup": {
"hashes": [
"sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5",
"sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"
],
"markers": "python_version < '3.11'",
"version": "==1.1.2"
},
"flask": {
"hashes": [
"sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0",
@ -314,22 +298,6 @@
"markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
"version": "==2.0.2"
},
"h11": {
"hashes": [
"sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d",
"sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"
],
"markers": "python_version >= '3.7'",
"version": "==0.14.0"
},
"httpcore": {
"hashes": [
"sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888",
"sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"
],
"markers": "python_version >= '3.8'",
"version": "==0.17.3"
},
"idna": {
"hashes": [
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
@ -420,11 +388,11 @@
},
"marshmallow": {
"hashes": [
"sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78",
"sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b"
"sha256:5d2371bbe42000f2b3fb5eaa065224df7d8f8597bc19a1bbfa5bfe7fba8da889",
"sha256:684939db93e80ad3561392f47be0230743131560a41c5110684c16e21ade0a5c"
],
"markers": "python_version >= '3.7'",
"version": "==3.19.0"
"markers": "python_version >= '3.8'",
"version": "==3.20.1"
},
"marshmallow-sqlalchemy": {
"hashes": [
@ -512,11 +480,11 @@
},
"pyjwt": {
"hashes": [
"sha256:ba2b425b15ad5ef12f200dc67dd56af4e26de2331f965c5439994dad075876e1",
"sha256:bd6ca4a3c4285c1a2d4349e5a035fdf8fb94e04ccd0fcbe6ba289dae9cc3e074"
"sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de",
"sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"
],
"index": "pypi",
"version": "==2.7.0"
"version": "==2.8.0"
},
"pymupdf": {
"hashes": [
@ -570,14 +538,6 @@
"index": "pypi",
"version": "==2.31.0"
},
"sniffio": {
"hashes": [
"sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101",
"sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.0"
},
"sqlalchemy": {
"hashes": [
"sha256:024d2f67fb3ec697555e48caeb7147cfe2c08065a4f1a52d93c3d44fc8e6ad1c",
@ -643,11 +603,11 @@
},
"urllib3": {
"hashes": [
"sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1",
"sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"
"sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11",
"sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"
],
"markers": "python_version >= '3.7'",
"version": "==2.0.3"
"version": "==2.0.4"
},
"visitor": {
"hashes": [
@ -670,6 +630,14 @@
],
"markers": "python_version >= '3.7'",
"version": "==3.0.1"
},
"wtforms-sqlalchemy": {
"hashes": [
"sha256:7ca42824ad7c453a036f502b42d9c17d4ad8398ff9248a62ed538d1ce0bc41c3",
"sha256:90195d7592bf256d82498c42c79d416832e4a4e6fbca4f1e745a018f66d26c47"
],
"index": "pypi",
"version": "==0.3"
}
},
"develop": {}

View File

@ -0,0 +1,54 @@
"""receipt id serial
Revision ID: 0fa2ef37e440
Revises: f6f97ed9c053
Create Date: 2023-07-25 21:26:25.353435
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0fa2ef37e440'
down_revision = 'f6f97ed9c053'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.execute("CREATE SEQUENCE receipt_id_seq;")
with op.batch_alter_table('receipt', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=False,
server_default=sa.sql.func.next_value(sa.Sequence('receipt_id_seq')))
with op.batch_alter_table('receipt_item', schema=None) as batch_op:
batch_op.alter_column('receipt',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=False)
op.execute("ALTER SEQUENCE receipt_id_seq OWNED BY receipt.id;")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('receipt_item', schema=None) as batch_op:
batch_op.alter_column('receipt',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=False)
with op.batch_alter_table('receipt', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@ -9,3 +9,6 @@ class Brand(db.Model):
def __repr__(self) -> str:
return f"<Brand {self.id} ({self.name})>"
def __str__(self) -> str:
return f"{self.name}"

View File

@ -21,3 +21,6 @@ class Item(db.Model):
def __repr__(self) -> str:
return f"<Item {self.id} ({self.name})>"
def __str__(self) -> str:
return f"({self.id}) {self.description}"

View File

@ -1,5 +1,7 @@
from flask import Blueprint
bp = Blueprint('receipts', __name__, url_prefix='/receipts')
from . import forms, routes
from .upload import bp as bp_upload
bp.register_blueprint(bp_upload)
from .check_items import bp as bp_check_items
bp.register_blueprint(bp_check_items)

View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('check_items', __name__, url_prefix='/check_items')
from . import forms, routes

View File

@ -0,0 +1,63 @@
from collections import namedtuple
from flask_wtf import FlaskForm
from models import Brand, Item
from src.utils.models.query_factories import all_brands, all_items
from wtforms import BooleanField, HiddenField, FieldList, Form, FormField, IntegerField, RadioField, SelectField, StringField, SubmitField
from wtforms.validators import DataRequired, Optional, ValidationError
from wtforms_sqlalchemy.fields import QuerySelectField
class CheckItemsEntryForm(Form):
# TODO Fertig machen x.x
itemname = HiddenField('itemname', validators=[DataRequired()])
price = HiddenField('price', validators=[DataRequired()])
requesting = BooleanField("", default=False, render_kw={
"class": "form-check-input"})
new_or_existing = RadioField("", choices=[
(0, "New"), (1, "Existing")], render_kw={"class": "form-check-input", "style": "display:none"}, validate_choice=False)
# Fields for new Item
new_description = StringField("Description", render_kw={"class": "form-control"})
new_amount_change = IntegerField("Amount", render_kw={"class": "form-control"})
new_brand = QuerySelectField("Brand", query_factory=all_brands, render_kw={"class": "form-control"}, allow_blank=True)
# Fields for existing Item
existing_item = QuerySelectField("Item", query_factory=all_items, render_kw={"class": "form-control"}, allow_blank=True)
def validate_new_or_existing(self, new_or_existing):
if (self.requesting.data and not new_or_existing.data):
raise ValidationError(
"Please choose if it's a new or existing Item.")
def validate_existing_item(self, existing_item):
if (existing_item.data and self.new_or_existing.data == 0):
raise ValidationError("You shouldn't be able to enter this.")
def validate_new_description(self, new_description):
if (new_description.data and self.new_or_existing.data == 1):
raise ValidationError("You shouldn't be able to enter this.")
def validate_new_amount_change(self, new_amount_change):
if (new_amount_change.data and self.new_or_existing.data == 1):
raise ValidationError("You shouldn't be able to enter this.")
def validate_new_brand(self, new_brand):
if (new_brand.data and self.new_or_existing.data == 1):
raise ValidationError("You shouldn't be able to enter this.")
class CheckItemsForm(FlaskForm):
items = FieldList(FormField(CheckItemsEntryForm))
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
@classmethod
def new(cls, itemarray):
CheckItemsEntry = namedtuple(
"CheckItemsEntry", ["itemname", "price", "new_brand"])
CheckItems = namedtuple("CheckItems", ["items"])
check_items_entry = []
for item in itemarray:
check_items_entry.append(CheckItemsEntry(item['itemname'], item['price'], 0))
check_items = CheckItems(check_items_entry)
form = cls(obj=check_items)
print(f"{form.items.entries}")
return form

View File

@ -0,0 +1,41 @@
from flask import abort, request, url_for
from flask_login import current_user, login_required
from . import bp
from .forms import CheckItemsForm
from src import db, LOGGER
from models.receipt import Receipt
from models.login_token import LoginToken
from src.utils.pdf_receipt_parser import PDFReceipt
from src.utils.routes_utils import render_custom_template as render_template
PDFDir = "./"
@bp.route('/<int:receipt_id>', methods=['GET', 'POST'])
@login_required
def confirm_receipt_items(receipt_id: int):
"""Check items from a receipt if they should be accounted for payment.
Get those items from the receipt PDF itself."""
receipt_details = Receipt.query.get(receipt_id)
if current_user.is_authenticated and current_user.id == receipt_details.LoginToken.Establishment.owner:
receipt = PDFReceipt.getPDFReceiptFromFile(PDFDir + f"{receipt_details.id}.pdf")
form = CheckItemsForm.new(receipt.items)
# TODO: Precheck if items are already in database. If yes, check if item is present only once or multiple
# times and provide dropdown menu if necessary. If not, provide input field.
# temp_choices = []
# for item in receipt.items:
# match item:
# case {"itemname": itemname, "price": price}:
# temp_choices.append((itemname.replace(" ", "_"), f"{itemname, price}"))
# case {"itemname": itemname, "price": price, "amount": amount}:
# temp_choices.append((itemname.replace(" ", "_"), f"{itemname}, {price} * {amount}"))
# form.choices = temp_choices
# print(form.data)
for formitem in form.items:
# print(formitem.new_brand.__dict__)
print(formitem.data)
if form.validate():
print("valid")
if form.validate_on_submit():
return form.items.data
return render_template("receipts/check_items.html", form=form)
abort(403)

View File

@ -1,24 +0,0 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from wtforms import BooleanField, SelectMultipleField, SubmitField, widgets
from wtforms.validators import Optional
class MultiCheckboxField(SelectMultipleField):
widget = widgets.ListWidget(prefix_label=False)
option_widget = widgets.CheckboxInput()
class UploadReceiptForm(FlaskForm):
pdfReceipt = FileField("PDF", validators=[FileRequired(), FileAllowed(["pdf"], "Invalid Format, must be .pdf")])
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
class CheckItemsForm(FlaskForm):
items = MultiCheckboxField("Items")
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
#TODO create new() method which loads the form for a specific receipt
@classmethod
def new(cls, itemArray):
"""
"""
form = cls()
return form

View File

@ -1,35 +0,0 @@
from flask import abort, request, url_for
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from . import bp
from .forms import UploadReceiptForm
from src import db, LOGGER
from models.receipt import Receipt
from models.login_token import LoginToken
from src.utils.pdf_receipt_parser import PDFReceipt
from src.utils.routes_utils import render_custom_template as render_template
PDFDir = "./"
@bp.route('/upload_receipt', methods=['GET', 'POST'])
@login_required
def upload_receipt():
"""Upload of a receipt."""
if current_user.is_anonymous:
abort(403)
if "establishment" in request.args:
if LoginToken.query.filter_by(establishment=request.args['establishment'], user=current_user.id).first():
form = UploadReceiptForm()
LOGGER.debug(form.pdfReceipt.data)
if form.validate_on_submit():
receipt = PDFReceipt(form.pdfReceipt.data)
dbReceipt = Receipt(id = receipt.id, date = receipt.date,
from_user = LoginToken.query.filter_by(establishment=request.args['establishment'], user=current_user.id).first().token)
form.pdfReceipt.data.save(PDFDir + secure_filename(f"{str(receipt.date)}_{receipt.id}.pdf"))
db.session.add(dbReceipt)
db.session.commit()
return receipt.text.replace("\n", "<br>")
else:
LOGGER.debug(form.errors)
return render_template("receipts/upload.html", form = form)
abort(403)

View File

@ -1,5 +1,6 @@
from flask import abort, request, url_for
from flask_login import current_user, login_required
from os import rename
from werkzeug.utils import secure_filename
from . import bp
from .forms import UploadReceiptForm
@ -11,7 +12,7 @@ from src.utils.routes_utils import render_custom_template as render_template
PDFDir = "./"
# TODO überarbeiten. PDFs müssen in der Datenbank eine eigene ID bekommen.
# Quittungen haben eine USt.-ID. Die muss als Unique Key in der Datenbank
# Quittungen haben eine gesonderte ID. Die muss als Unique Key in der Datenbank
# hinterlegt sein.
# Die laufende ID ist zum abspeichern der PDFs gedacht.
@bp.route('/<int:establishment>', methods=['GET', 'POST'])
@ -20,16 +21,19 @@ def upload_receipt(establishment: int):
"""Upload of a receipt."""
if current_user.is_anonymous:
abort(403)
if LoginToken.query.filter_by(establishment=request.args['establishment'], user=current_user.id).first():
if LoginToken.query.filter_by(establishment=establishment, user=current_user.id).first():
form = UploadReceiptForm()
LOGGER.debug(form.pdfReceipt.data)
if form.validate_on_submit():
receipt = PDFReceipt(form.pdfReceipt.data)
dbReceipt = Receipt(id = receipt.id, date = receipt.date,
from_user = LoginToken.query.filter_by(establishment=request.args['establishment'], user=current_user.id).first().token)
form.pdfReceipt.data.save(PDFDir + secure_filename(f"{str(receipt.date)}_{receipt.id}.pdf"))
pdfReceipt = form.pdfReceipt.data
pdfReceipt.save(f"{PDFDir}/temp.pdf")
with open(f"{PDFDir}/temp.pdf") as doc:
receipt = PDFReceipt(doc)
dbReceipt = Receipt(bonid = receipt.id, date = receipt.date,
from_user = LoginToken.query.filter_by(establishment=establishment, user=current_user.id).first().token)
db.session.add(dbReceipt)
db.session.commit()
rename(f"{PDFDir}/temp.pdf", f"{PDFDir}{secure_filename(f'{dbReceipt.id}.pdf')}")
return receipt.text.replace("\n", "<br>")
else:
LOGGER.debug(form.errors)

View File

@ -0,0 +1,10 @@
from models import Brand, Category, Item
def all_brands():
return Brand.query.order_by("name")
def all_categories():
return Category.query.order_by("name")
def all_items():
return Item.query.order_by("id")

View File

@ -122,5 +122,5 @@ CostHive
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"></script>
<!-- <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% from 'utils/form/_render_field.html' import render_field %}
{% block app_content %}
<form action="" method="post" novalidate enctype="multipart/form-data">
{{ form.hidden_tag() }}
{% for item in form.items %}
<h4>{{ item.requesting() }} {{ item.data.itemname }} (€{{ item.data.price }})</h4>
{{ render_field(item.itemname) }}
{{ render_field(item.price) }}
<div class="{{ item.new_or_existing.id }}" style="display: none;">
{{ render_field(item.new_or_existing) }}
</div>
<div class="{{ item.id }}_new" style="display: none;">
{{ render_field(item.new_description) }}
{{ render_field(item.new_amount_change) }}
{{ render_field(item.new_brand) }}
</div>
<div class="{{ item.id }}_existing" style="display: none;">
{{ render_field(item.existing_item) }}
</div>
<br>
{% endfor %}
{{ form.submit() }}
</form>
{% endblock %}
{% block scripts %}
{{ super() }}
{% for item in form.items %}
<script>
$(document).ready(function () {
$("#{{ item.requesting.id }}").change(function () {
if ($(this).prop("checked")) {
$(".{{ item.new_or_existing.id }}").show();
if ($("#{{ item.new_or_existing.id }}-0").prop("checked")) {
$(".{{ item.id }}_new").show();
}
if ($("#{{ item.new_or_existing.id }}-1").prop("checked")) {
$(".{{ item.id }}_existing").show();
}
}
else {
$(".{{ item.new_or_existing.id }}").hide();
$(".{{ item.id }}_new").hide();
$(".{{ item.id }}_existing").hide();
}
});
$("#{{ item.new_or_existing.id }}-0").change(function () {
if ($(this).prop("checked")) {
$(".{{ item.id }}_new").show();
$(".{{ item.id }}_existing").hide();
}
});
$("#{{ item.new_or_existing.id }}-1").change(function () {
if ($(this).prop("checked")) {
$(".{{ item.id }}_new").hide();
$(".{{ item.id }}_existing").show();
}
});
});
</script>
{% endfor %}
{% endblock %}

View File

@ -1,11 +1,13 @@
{% from 'utils/form/_render_radio_button.html' import render_field as render_radio %}
{% from 'utils/form/_render_generic_field.html' import render_field as render_generic %}
{% from 'utils/form/_render_hidden_field.html' import render_field as render_hidden %}
{% macro render_field(field) %}
<div class="form-group">
{{ field.label }} {{ field(**kwargs)|safe }} {% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if field.__class__.__name__ == "RadioField" %}
{{ render_radio(field) }}
{% elif field.__class__.__name__ == "HiddenField" %}
{{ render_hidden(field) }}
{% else %}
{{ render_generic(field)}}
{% endif %}
{% endmacro %}

View File

@ -0,0 +1,11 @@
{% macro render_fieldlist(field) %}
<div class="form-group">
{{ field.label }} {{ field(**kwargs)|safe }} {% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}

View File

@ -0,0 +1,11 @@
{% macro render_field(field) %}
<div class="form-group">
{{ field.label }} {{ field(**kwargs)|safe }} {% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}

View File

@ -0,0 +1,11 @@
{% macro render_field(field) %}
<div class="form-group">
{{ field(**kwargs)|safe }} {% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}

View File

@ -0,0 +1,16 @@
{% macro render_field(field) %}
{% for choice in field.choices %}
<div class="form-check form-check-inline">
<input class="{{ field.render_kw['class'] }}" type="radio" name="{{ field.id }}" id="{{ field.id }}-{{ choice[0] }}"
value="{{ choice[0] }}">
<label class="form-check-label" for="{{ field.id }}-{{ choice[0] }}">{{ choice[1] }}</label>
</div>
{% endfor %}
{% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}

Binary file not shown.

BIN
requirements.txt.backup Normal file

Binary file not shown.