Holger Frey
5 years ago
24 changed files with 640 additions and 10 deletions
@ -0,0 +1,75 @@ |
|||||||
|
### |
||||||
|
# app configuration |
||||||
|
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html |
||||||
|
### |
||||||
|
|
||||||
|
[app:main] |
||||||
|
use = egg:superx_budget |
||||||
|
|
||||||
|
session.secret = "change me in production" |
||||||
|
budgets.dir = %(here)s/test_data |
||||||
|
|
||||||
|
pyramid.reload_templates = true |
||||||
|
pyramid.includes = |
||||||
|
pyramid_mailer.debug |
||||||
|
|
||||||
|
mail.host = "localhost" # SMTP host |
||||||
|
mail.port = 2525 # SMTP port |
||||||
|
mail.username = "" # SMTP username |
||||||
|
mail.password = "" # SMTP password |
||||||
|
mail.tls = false # Use TLS |
||||||
|
mail.ssl = false # Use SSL |
||||||
|
mail.default_sender = "" # Default from address |
||||||
|
mail.debug = 0 # SMTP debug level |
||||||
|
mail.debug_include_bcc = true # Include Bcc headers when Debugging |
||||||
|
|
||||||
|
[pshell] |
||||||
|
setup = ordr3.pshell.setup |
||||||
|
|
||||||
|
### |
||||||
|
# wsgi server configuration |
||||||
|
### |
||||||
|
|
||||||
|
[server:main] |
||||||
|
use = egg:waitress#main |
||||||
|
listen = localhost:6543 |
||||||
|
|
||||||
|
### |
||||||
|
# logging configuration |
||||||
|
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html |
||||||
|
### |
||||||
|
|
||||||
|
[loggers] |
||||||
|
keys = root, ordr3, sqlalchemy |
||||||
|
|
||||||
|
[handlers] |
||||||
|
keys = console |
||||||
|
|
||||||
|
[formatters] |
||||||
|
keys = generic |
||||||
|
|
||||||
|
[logger_root] |
||||||
|
level = INFO |
||||||
|
handlers = console |
||||||
|
|
||||||
|
[logger_ordr3] |
||||||
|
level = DEBUG |
||||||
|
handlers = |
||||||
|
qualname = ordr3 |
||||||
|
|
||||||
|
[logger_sqlalchemy] |
||||||
|
level = WARN |
||||||
|
handlers = |
||||||
|
qualname = sqlalchemy.engine |
||||||
|
# "level = INFO" logs SQL queries. |
||||||
|
# "level = DEBUG" logs SQL queries and results. |
||||||
|
# "level = WARN" logs neither. (Recommended for production systems.) |
||||||
|
|
||||||
|
[handler_console] |
||||||
|
class = StreamHandler |
||||||
|
args = (sys.stderr,) |
||||||
|
level = NOTSET |
||||||
|
formatter = generic |
||||||
|
|
||||||
|
[formatter_generic] |
||||||
|
format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s |
@ -0,0 +1,47 @@ |
|||||||
|
""" Superx Budget GUI """ |
||||||
|
|
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
from pyramid.view import notfound_view_config |
||||||
|
from pyramid.config import Configurator |
||||||
|
from pyramid.session import JSONSerializer, SignedCookieSessionFactory |
||||||
|
from pyramid.httpexceptions import HTTPFound |
||||||
|
|
||||||
|
from ..overview import create_overview # noqa: F401 |
||||||
|
from ..exceptions import BudgetParserError, SuperXParserError # noqa: F401 |
||||||
|
|
||||||
|
XLSX_CONTENT_TYPE = "application/vnd.ms-excel" |
||||||
|
|
||||||
|
|
||||||
|
def root_factory(request): |
||||||
|
return {} |
||||||
|
|
||||||
|
|
||||||
|
def main(global_config, **settings): |
||||||
|
""" This function returns a Pyramid WSGI application. """ |
||||||
|
with Configurator(settings=settings) as config: |
||||||
|
|
||||||
|
config.include("pyramid_jinja2") |
||||||
|
|
||||||
|
session_factory = SignedCookieSessionFactory( |
||||||
|
settings["session.secret"], serializer=JSONSerializer() |
||||||
|
) |
||||||
|
config.set_session_factory(session_factory) |
||||||
|
|
||||||
|
config.set_root_factory(root_factory) |
||||||
|
|
||||||
|
config.add_request_method( |
||||||
|
lambda r: Path(settings["budgets.dir"]), "budgets_dir", reify=True, |
||||||
|
) |
||||||
|
|
||||||
|
age = int(settings.get("static_views.cache_max_age", 0)) |
||||||
|
config.add_static_view("static", "static", cache_max_age=age) |
||||||
|
|
||||||
|
config.scan() |
||||||
|
|
||||||
|
return config.make_wsgi_app() |
||||||
|
|
||||||
|
|
||||||
|
@notfound_view_config() |
||||||
|
def not_found(context, request): |
||||||
|
return HTTPFound("/") |
@ -0,0 +1,145 @@ |
|||||||
|
""" Views for the create overview part """ |
||||||
|
|
||||||
|
from tempfile import NamedTemporaryFile |
||||||
|
|
||||||
|
from pyramid.view import view_config |
||||||
|
from pyramid.httpexceptions import HTTPFound |
||||||
|
from pyramid_mailer.message import Message, Attachment |
||||||
|
|
||||||
|
from . import XLSX_CONTENT_TYPE |
||||||
|
from ..budget import parse_budget_file |
||||||
|
from ..superx import parse_exported_file |
||||||
|
from ..helpers import find_recipients, find_budget_file, get_sheet_of_file |
||||||
|
from ..overview import create_overview # noqa: F401 |
||||||
|
from ..exceptions import BudgetParserError, SuperXParserError # noqa: F401 |
||||||
|
|
||||||
|
|
||||||
|
@view_config( |
||||||
|
context=dict, |
||||||
|
request_method="GET", |
||||||
|
renderer="superx_budget:pyramid/templates/start.jinja2", |
||||||
|
) |
||||||
|
def index(context, request): |
||||||
|
return {} |
||||||
|
|
||||||
|
|
||||||
|
@view_config( |
||||||
|
context=dict, |
||||||
|
request_method="POST", |
||||||
|
renderer="superx_budget:pyramid/templates/overview.jinja2", |
||||||
|
) |
||||||
|
def superx_upload(context, request): |
||||||
|
upload = request.POST.get("superx") |
||||||
|
|
||||||
|
if upload == b"" or not upload.filename.endswith(".xlsx"): |
||||||
|
request.session.flash("No Excel file uploaded.", "error") |
||||||
|
return HTTPFound("/") |
||||||
|
|
||||||
|
try: |
||||||
|
superx_export = parse_exported_file(upload.file) |
||||||
|
except SuperXParserError: |
||||||
|
request.session.flash( |
||||||
|
"File does not appear to be the required SuperX export.", "error" |
||||||
|
) |
||||||
|
return HTTPFound("/") |
||||||
|
|
||||||
|
budget_file = find_budget_file( |
||||||
|
request.budgets_dir, superx_export.account_year |
||||||
|
) |
||||||
|
if budget_file is None: |
||||||
|
request.session.flash( |
||||||
|
f"No budget file for year {superx_export.account_year} found.", |
||||||
|
"error", |
||||||
|
) |
||||||
|
return HTTPFound("/") |
||||||
|
|
||||||
|
try: |
||||||
|
budget_data = parse_budget_file(budget_file) |
||||||
|
except BudgetParserError: |
||||||
|
request.session.flash( |
||||||
|
"Budget File does not appear to be in the required format.", |
||||||
|
"error", |
||||||
|
) |
||||||
|
return HTTPFound("/") |
||||||
|
|
||||||
|
overview_map = create_overview(budget_data, superx_export) |
||||||
|
overview = sorted(overview_map.values(), key=lambda i: i.row) |
||||||
|
|
||||||
|
if any(not (item.found) for item in overview): |
||||||
|
request.session.flash( |
||||||
|
( |
||||||
|
"Some projects in the budget template were not in the SuperX " |
||||||
|
"export. Please adjust their expenses manually." |
||||||
|
), |
||||||
|
"info", |
||||||
|
) |
||||||
|
|
||||||
|
recipients = find_recipients(request.budgets_dir) |
||||||
|
|
||||||
|
return { |
||||||
|
"account_year": superx_export.account_year, |
||||||
|
"export_date": superx_export.export_date.strftime("%Y-%m-%d"), |
||||||
|
"overview": overview, |
||||||
|
"template": budget_file.name, |
||||||
|
"recipients": recipients, |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@view_config( |
||||||
|
context=dict, |
||||||
|
name="send", |
||||||
|
request_method="POST", |
||||||
|
renderer="superx_budget:pyramid/templates/sent.jinja2", |
||||||
|
) |
||||||
|
def send_overview(context, request): |
||||||
|
export_date = request.POST.get("export_date").strip() |
||||||
|
tmp_recipients = request.POST.get("recipients").strip() |
||||||
|
recipients = tmp_recipients.splitlines() |
||||||
|
budget_template = request.POST.get("template") |
||||||
|
budget_file = request.budgets_dir / budget_template |
||||||
|
expenses = {} |
||||||
|
for key, value in request.POST.items(): |
||||||
|
if key.startswith("expense-"): |
||||||
|
row_str = key.split("-")[-1] |
||||||
|
row = int(row_str) |
||||||
|
try: |
||||||
|
value = float(value) |
||||||
|
except ValueError: |
||||||
|
value = 0 |
||||||
|
expenses[row] = value |
||||||
|
|
||||||
|
# sanity check |
||||||
|
if ( |
||||||
|
not export_date |
||||||
|
or not recipients |
||||||
|
or not expenses |
||||||
|
or not budget_file.is_file() |
||||||
|
): |
||||||
|
request.session.flash( |
||||||
|
f"There was an error with your submisssion, please try again.", |
||||||
|
"error", |
||||||
|
) |
||||||
|
return HTTPFound("/") |
||||||
|
|
||||||
|
sheet = get_sheet_of_file(budget_file) |
||||||
|
for row, value in expenses.items(): |
||||||
|
cell = f"F{row}" |
||||||
|
sheet[cell] = value |
||||||
|
|
||||||
|
message = Message( |
||||||
|
subject=f"Budget Übersicht, Stand {export_date}", |
||||||
|
sender="cpiserver@imtek.uni-freiburg.de", |
||||||
|
recipients=recipients, |
||||||
|
body="hello from ford", |
||||||
|
) |
||||||
|
|
||||||
|
budget_year = budget_file.stem.split("-")[-1] |
||||||
|
xls_name = f"{export_date}-Budget-Overview-{budget_year}.xlsx" |
||||||
|
with NamedTemporaryFile() as tmp: |
||||||
|
sheet._parent.save(tmp.name) |
||||||
|
tmp.seek(0) |
||||||
|
attachment = Attachment(xls_name, XLSX_CONTENT_TYPE, tmp) |
||||||
|
message.attach(attachment) |
||||||
|
request.mailer.send(message) |
||||||
|
|
||||||
|
return {"recipients": recipients, "xls_name": xls_name} |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,20 @@ |
|||||||
|
.form input.form-control.currency { |
||||||
|
display: inline-block; |
||||||
|
vertical-align:middle; |
||||||
|
width: 7em; |
||||||
|
border-color:#007bff!important; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* Remove Spinner from number input fields */ |
||||||
|
/* For Firefox */ |
||||||
|
input[type='number'] { |
||||||
|
-moz-appearance:textfield; |
||||||
|
} |
||||||
|
|
||||||
|
/* Webkit browsers like Safari and Chrome */ |
||||||
|
input[type=number]::-webkit-inner-spin-button, |
||||||
|
input[type=number]::-webkit-outer-spin-button { |
||||||
|
-webkit-appearance: none; |
||||||
|
margin: 0; |
||||||
|
} |
@ -0,0 +1,82 @@ |
|||||||
|
""" Views for the templates part """ |
||||||
|
|
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
from pyramid.view import view_config |
||||||
|
from pyramid.response import FileResponse |
||||||
|
from pyramid.httpexceptions import HTTPFound |
||||||
|
|
||||||
|
from . import XLSX_CONTENT_TYPE |
||||||
|
from ..budget import parse_budget_file |
||||||
|
from ..helpers import list_budget_files, is_budget_file_name |
||||||
|
from ..overview import create_overview # noqa: F401 |
||||||
|
from ..exceptions import BudgetParserError, SuperXParserError # noqa: F401 |
||||||
|
|
||||||
|
|
||||||
|
@view_config( |
||||||
|
context=dict, |
||||||
|
name="templates", |
||||||
|
request_method="GET", |
||||||
|
renderer="superx_budget:pyramid/templates/templates.jinja2", |
||||||
|
) |
||||||
|
def templates(context, request): |
||||||
|
if "f" in request.GET: |
||||||
|
file_name = request.GET["f"] |
||||||
|
file_path = request.budgets_dir / file_name |
||||||
|
if not file_path.is_file(): |
||||||
|
return HTTPFound("/templates") |
||||||
|
response = FileResponse( |
||||||
|
file_path, |
||||||
|
request=request, |
||||||
|
cache_max_age=0, |
||||||
|
content_type=XLSX_CONTENT_TYPE, |
||||||
|
) |
||||||
|
response.headers[ |
||||||
|
"Content-Disposition" |
||||||
|
] = f"attachment;filename={file_name}" |
||||||
|
return response |
||||||
|
|
||||||
|
tmp = list_budget_files(request.budgets_dir) |
||||||
|
tmp = sorted(tmp, key=lambda p: p.stem[-4:]) |
||||||
|
budget_files = [path.name for path in tmp] |
||||||
|
return {"budget_files": budget_files} |
||||||
|
|
||||||
|
|
||||||
|
@view_config( |
||||||
|
context=dict, |
||||||
|
name="templates", |
||||||
|
request_method="POST", |
||||||
|
renderer="superx_budget:pyramid/templates/templates.jinja2", |
||||||
|
) |
||||||
|
def templates_update(context, request): |
||||||
|
upload = request.POST.get("budget") |
||||||
|
|
||||||
|
if upload == b"" or not is_budget_file_name(upload.filename): |
||||||
|
request.session.flash("No Excel file uploaded.", "error") |
||||||
|
return HTTPFound("/templates") |
||||||
|
|
||||||
|
try: |
||||||
|
parse_budget_file(upload.file) |
||||||
|
except BudgetParserError: |
||||||
|
request.session.flash( |
||||||
|
"File does not appear to be in the required format.", "error" |
||||||
|
) |
||||||
|
return HTTPFound("/templates") |
||||||
|
|
||||||
|
# sanitizing upload filename: |
||||||
|
file_name = Path(upload.filename).name |
||||||
|
|
||||||
|
tmp = list_budget_files(request.budgets_dir) |
||||||
|
available_files = {p.name.lower(): p for p in tmp} |
||||||
|
old_path = available_files.get(file_name.lower()) |
||||||
|
if old_path: |
||||||
|
old_path.unlink() |
||||||
|
|
||||||
|
new_path = request.budgets_dir / file_name |
||||||
|
with new_path.open("wb") as fh: |
||||||
|
upload.file.seek(0) |
||||||
|
fh.write(upload.file.read()) |
||||||
|
|
||||||
|
request.session.flash("File upload successful.", "info") |
||||||
|
|
||||||
|
return HTTPFound("/templates") |
@ -0,0 +1,72 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> |
||||||
|
|
||||||
|
<title>SuperX -> Budget Overview</title> |
||||||
|
|
||||||
|
<link href="{{request.static_url('superx_budget.pyramid:static/img/favicon.ico')}}" type="image/x-icon" rel="shortcut icon"> |
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"> |
||||||
|
<link rel="stylesheet" href="{{request.static_url('superx_budget.pyramid:static/style.css')}}" type="text/css" media="screen" /> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="container"> |
||||||
|
|
||||||
|
<div class="row mb-4"> |
||||||
|
<div class="col"> |
||||||
|
<header> |
||||||
|
<nav class="navbar navbar-expand navbar-light bg-light"> |
||||||
|
<span class="navbar-brand bg-info p-3 rounded">Budget Overview From SuperX</span> |
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav"> |
||||||
|
<ul class="navbar-nav na"> |
||||||
|
<li class="nav-item {% if request.view_name !='templates' %}active{% endif %}"> |
||||||
|
<a class="nav-link" href="/">Create Budget Overview <span class="sr-only">(current)</span></a> |
||||||
|
</li> |
||||||
|
|
||||||
|
<li class="nav-item {% if request.view_name =='templates' %}active{% endif %}"> |
||||||
|
<a class="nav-link" href="/templates">Budget Templates</a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</nav> |
||||||
|
</header> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if request.session.peek_flash('error') %} |
||||||
|
<div class="row"> |
||||||
|
<div class="col"> |
||||||
|
<div class="alert alert-danger" role="alert"> |
||||||
|
{{ request.session.pop_flash('error')[0] }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{%endif%} |
||||||
|
{% if request.session.peek_flash('info') %} |
||||||
|
<div class="row"> |
||||||
|
<div class="col"> |
||||||
|
<div class="alert alert-primary" role="alert"> |
||||||
|
{{ request.session.pop_flash('info')[0] }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{%endif%} |
||||||
|
|
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<p>No content</p> |
||||||
|
{% endblock content %} |
||||||
|
|
||||||
|
<div class="row mt-3"> |
||||||
|
<div class="col"> |
||||||
|
<footer> |
||||||
|
<p class="bg-light p-3">Any problems or questions? Please contact <a href="https://wiki.cpi.imtek.uni-freiburg.de/HolgerFrey">Holgi</a>.</p> |
||||||
|
</footer> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
</div></div></div> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,63 @@ |
|||||||
|
{% extends "superx_budget:pyramid/templates/layout.jinja2" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
|
||||||
|
<div class="row"> |
||||||
|
<div class="col"> |
||||||
|
<h2> |
||||||
|
Budget Overview {{ account_year }} |
||||||
|
<small class="text-muted">as of {{ export_date }}</small> |
||||||
|
</h2> |
||||||
|
|
||||||
|
<form class="form" action="/send" method="post"> |
||||||
|
<input type="hidden" name="export_date" value="{{ export_date }}" > |
||||||
|
<input type="hidden" name="template" value="{{ template }}" > |
||||||
|
<table class="table table-striped"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th scope="col">Project</th> |
||||||
|
<th scope="col">PSP</th> |
||||||
|
<th scope="col" class="text-right">Budget</th> |
||||||
|
<th scope="col" class="text-right">Expenses</th> |
||||||
|
<th scope="col" class="text-right">Rest</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
|
||||||
|
{% for budget in overview %} |
||||||
|
|
||||||
|
<tr scope="row" class="{% if budget.available<0 %}table-danger{% endif %}"> |
||||||
|
<td> |
||||||
|
{{budget.budget_data.project_name}} |
||||||
|
</td> |
||||||
|
<td>{{budget.budget_data.project}}</td> |
||||||
|
<td class="text-right">{{ "{:,.2f}".format(budget.budget_data.budget) }} €</td> |
||||||
|
<td class="text-right"> |
||||||
|
{% if budget.found %} |
||||||
|
<input type="hidden" name="expense-{{budget.row}}" value="{{budget.expenses}}"> |
||||||
|
{{ "{:,.2f}".format(budget.expenses) }} € |
||||||
|
|
||||||
|
{% else %} |
||||||
|
<input type="number" name="expense-{{budget.row}}" value="0" min="0" required="required" class="currency text-right form-control"> € |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td class="text-right">{{ "{:,.2f}".format(budget.available) }} €</td> |
||||||
|
</tr> |
||||||
|
|
||||||
|
{% endfor %} |
||||||
|
|
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
<h4 class="mt-5">Who should receive the list </h4> |
||||||
|
<label for="recipients">Please separate the recipients by new lines:</label> |
||||||
|
<textarea class="form-control" name="recipients" id="recipients" rows="{{ recipients|count + 3 }}">{{ recipients|join("\n") }}{{ "\n" }}</textarea> |
||||||
|
<p class="mt-5"> |
||||||
|
<button type="submit" name="submit" value="submitted" class="btn btn-primary">Send List</button> |
||||||
|
</p> |
||||||
|
|
||||||
|
</form> |
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock content %} |
@ -0,0 +1,16 @@ |
|||||||
|
{% extends "superx_budget:pyramid/templates/layout.jinja2" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="row"> |
||||||
|
<div class="col"> |
||||||
|
<h2>Email sent!</h2> |
||||||
|
<p>An email with the attatched file "<span class="text-info">{{ xls_name }}</span>" was sent to the following recipients: |
||||||
|
<ul> |
||||||
|
{% for r in recipients: %} |
||||||
|
<li>{{ r }}</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
<h4>You can now close this browser window.</h4> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock content %} |
@ -0,0 +1,26 @@ |
|||||||
|
{% extends "superx_budget:pyramid/templates/layout.jinja2" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
|
||||||
|
<div class="row"> |
||||||
|
<div class="col"> |
||||||
|
<h2>Procedure to get the right SuperX export</h2> |
||||||
|
<ol> |
||||||
|
<li>Log in to SuperX</li> |
||||||
|
<li>select …</li> |
||||||
|
<li>select …</li> |
||||||
|
<li>select …</li> |
||||||
|
<li>select …</li> |
||||||
|
<li>select …</li> |
||||||
|
</ol> |
||||||
|
<h4>Done?</h4> |
||||||
|
<form enctype="multipart/form-data" method="post" action="/"> |
||||||
|
<div class="form-group"> |
||||||
|
<label for="fileupload">Then please upload the just exported Excel file:</label> |
||||||
|
<input id="fileupload" name="superx" type="file" class="form-control-file" /> |
||||||
|
</div> |
||||||
|
<button type="submit" name="submit" value="submitted" class="btn btn-primary">Submit</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock content %} |
@ -0,0 +1,25 @@ |
|||||||
|
{% extends "superx_budget:pyramid/templates/layout.jinja2" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
|
||||||
|
<div class="row"> |
||||||
|
<div class="col"> |
||||||
|
<h2 class="mb-4">Budget Template Files</h2> |
||||||
|
<ul> |
||||||
|
{% for name in budget_files %} |
||||||
|
<li><a href="/templates?f={{ name }}">{{ name }}</a></li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
<h4 class="mt-4">Add a New Template</h4> |
||||||
|
<p> If you upload a file with an existing name, the currently stored file gets replaced – that's how you do updates.</p> |
||||||
|
<p> The filename must be in the format "<span class="text-info">budget[...]-[year].xlsx</span>"; character capitalization does not matter.</p> |
||||||
|
<form enctype="multipart/form-data" method="post" action="/templates"> |
||||||
|
<div class="form-group"> |
||||||
|
<label for="fileupload">Upload a new file:</label> |
||||||
|
<input id="fileupload" name="budget" type="file" class="form-control-file" /> |
||||||
|
</div> |
||||||
|
<button type="submit" name="submit" value="submitted" class="btn btn-primary">Upload</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock content %} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue