From 3bdf66cb98f24fd79de572a175d27a3761381978 Mon Sep 17 00:00:00 2001 From: Jake Charman Date: Tue, 11 Nov 2025 20:49:23 +0000 Subject: [PATCH] Refactoring --- .gitignore | 1 + src/contact.py | 65 ------- src/{index.py => jakecharman/__init__.py} | 60 +++++- src/jakecharman/contact.py | 70 +++++++ src/jakecharman/content.py | 180 ++++++++++++++++++ src/jakecharman/storage.py | 28 +++ src/{ => jakecharman}/templates/article.html | 0 src/{ => jakecharman}/templates/contact.html | 0 src/{ => jakecharman}/templates/error.html | 0 src/{ => jakecharman}/templates/footer.html | 0 src/{ => jakecharman}/templates/header.html | 0 src/{ => jakecharman}/templates/index.html | 0 src/{ => jakecharman}/templates/projects.html | 0 src/projects.py | 171 ----------------- src/projects.wsgi | 4 +- src/sitemap.py | 43 ----- 16 files changed, 336 insertions(+), 286 deletions(-) delete mode 100755 src/contact.py rename src/{index.py => jakecharman/__init__.py} (55%) mode change 100755 => 100644 create mode 100755 src/jakecharman/contact.py create mode 100755 src/jakecharman/content.py create mode 100644 src/jakecharman/storage.py rename src/{ => jakecharman}/templates/article.html (100%) rename src/{ => jakecharman}/templates/contact.html (100%) rename src/{ => jakecharman}/templates/error.html (100%) rename src/{ => jakecharman}/templates/footer.html (100%) rename src/{ => jakecharman}/templates/header.html (100%) rename src/{ => jakecharman}/templates/index.html (100%) rename src/{ => jakecharman}/templates/projects.html (100%) delete mode 100755 src/projects.py delete mode 100755 src/sitemap.py diff --git a/.gitignore b/.gitignore index f53d18e..047495f 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ .vscode +.buildinfo.json \ No newline at end of file diff --git a/src/contact.py b/src/contact.py deleted file mode 100755 index 7902516..0000000 --- a/src/contact.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/python3 - -from index import app -from os import environ -from flask import request, render_template -from requests import post, get -from uuid import uuid4 -from textwrap import dedent -from traceback import format_exc -def validate_turnstile(response: str, ip: str) -> bool: - turnstile_secret = environ['TURNSTILE_SECRET'] - cf_response = post( - url='https://challenges.cloudflare.com/turnstile/v0/siteverify', - data={ - 'secret': turnstile_secret, - 'response': response, - 'remoteip': ip, - 'idempotency_key': uuid4() - }, - timeout=30 - ).json() - - return cf_response.get('success', False) - -def send_to_discord(form: dict) -> bool: - try: - discord_hook = environ['DISCORD_WEBHOOK'] - except KeyError: - app.logger.error(format_exc()) - return False - discord_msg = dedent( - f''' - __**New Contact Form Response**__ - - **From:** {form.get('name')} <{form.get('email')}> - - ''') - if form.get("message") == '': - discord_msg += '*No Message*' - else: - discord_msg += f'>>> {form.get("message")}' - discord_response = post( - url=discord_hook, - data={ - 'username': form.get('name'), - 'content': discord_msg - }, - timeout=30 - ) - if discord_response.status_code == 204: - return True - app.logger.error(discord_response.status_code, discord_response.text) - return False - -@app.route('/contact/', methods=('GET', 'POST')) -def contact(): - if request.method == 'POST': - if not validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr): - return render_template('contact.html', error=True, user_message='You appear to be a robot.') - send_result = send_to_discord(request.form) - if send_result: - return render_template('contact.html', user_message='Your message has been sent!') - return render_template('contact.html', error=True, user_message='An error occurred.') - else: - return render_template('contact.html', page_title='Contact - ') diff --git a/src/index.py b/src/jakecharman/__init__.py old mode 100755 new mode 100644 similarity index 55% rename from src/index.py rename to src/jakecharman/__init__.py index 98057c4..6af898f --- a/src/index.py +++ b/src/jakecharman/__init__.py @@ -4,15 +4,27 @@ import traceback from os import environ import threading import logging +import xml.etree.ElementTree as ET +from os import path +from re import match +import json from requests import post -from flask import Flask, render_template, Response +from flask import Flask, render_template, Response, url_for, request +from .content import ContentArea +from .contact import ContactForm +from .storage import LocalStorage app = Flask(__name__) -# These imports need to come after our app is defined as they add routes to it. -import projects # pylint: disable=wrong-import-position,unused-import -import contact # pylint: disable=wrong-import-position,unused-import -import sitemap # pylint: disable=wrong-import-position,unused-import +md_path = path.join(path.realpath(path.dirname(__file__)), path.normpath('../projects/')) + +projects = ContentArea( + directory=LocalStorage(md_path), + name='projects', + import_name=__name__) + +app.register_blueprint(projects, url_prefix='/projects') +app.register_blueprint(ContactForm('contact', __name__), url_prefix='/contact') class DiscordLogger(logging.Handler): ''' Simple logging handler to send a message to Discord ''' @@ -82,3 +94,41 @@ def error(code) -> str: return render_template('error.html', error=f'{code}: {error_definitions.get(int(code))}', description=error_desc.get(int(code))) + +def get_routes() -> list: + ''' Get a list of all routes that make up the app ''' + routes = [] + for rule in app.url_map.iter_rules(): + if 0 >= len(rule.arguments): + url = url_for(rule.endpoint, **(rule.defaults or {})) + routes.append(url) + return routes + +def get_build_date(): + ''' Get the build date of the Docker container we're running in ''' + try: + with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build: + build_json = json.load(build) + return build_json['date'] + except Exception: # pylint: disable=broad-exception-caught + return '1970-01-01' + +@app.route('/sitemap.xml') +def sitemap(): + ''' Return an XML site map ''' + date = get_build_date() + root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9') + base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group() + base_url = base_url.replace('http://', 'https://') + for route in get_routes(): + url = ET.SubElement(root, 'url') + ET.SubElement(url, 'loc').text = base_url + route + ET.SubElement(url, 'lastmod').text = date + for article in projects.get_all_posts(): + if 'link' in article.metadata: + continue + url = ET.SubElement(root, 'url') + ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}' + ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d') + + return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'}) diff --git a/src/jakecharman/contact.py b/src/jakecharman/contact.py new file mode 100755 index 0000000..50ea412 --- /dev/null +++ b/src/jakecharman/contact.py @@ -0,0 +1,70 @@ +#!/usr/bin/python3 + +from os import environ +from uuid import uuid4 +from textwrap import dedent +from traceback import format_exc +from requests import post +from flask import request, render_template, Blueprint, current_app + +class ContactForm(Blueprint): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_url_rule('/', view_func=self.contact, methods=['GET', 'POST']) + + def validate_turnstile(self, response: str, ip: str) -> bool: + turnstile_secret = environ['TURNSTILE_SECRET'] + cf_response = post( + url='https://challenges.cloudflare.com/turnstile/v0/siteverify', + data={ + 'secret': turnstile_secret, + 'response': response, + 'remoteip': ip, + 'idempotency_key': uuid4() + }, + timeout=30 + ).json() + + return cf_response.get('success', False) + + def send_to_discord(self, form: dict) -> bool: + try: + discord_hook = environ['DISCORD_WEBHOOK'] + except KeyError: + current_app.logger.error(format_exc()) + return False + discord_msg = dedent( + f''' + __**New Contact Form Response**__ + + **From:** {form.get('name')} <{form.get('email')}> + + ''') + if form.get("message") == '': + discord_msg += '*No Message*' + else: + discord_msg += f'>>> {form.get("message")}' + discord_response = post( + url=discord_hook, + data={ + 'username': form.get('name'), + 'content': discord_msg + }, + timeout=30 + ) + if discord_response.status_code == 204: + return True + current_app.logger.error(discord_response.status_code, discord_response.text) + return False + + def contact(self): + if request.method == 'POST': + if not self.validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr): + return render_template('contact.html', error=True, user_message='You appear to be a robot.') + send_result = self.send_to_discord(request.form) + if send_result: + return render_template('contact.html', user_message='Your message has been sent!') + return render_template('contact.html', error=True, user_message='An error occurred.') + else: + return render_template('contact.html', page_title='Contact - ') diff --git a/src/jakecharman/content.py b/src/jakecharman/content.py new file mode 100755 index 0000000..527d0d3 --- /dev/null +++ b/src/jakecharman/content.py @@ -0,0 +1,180 @@ +#!/usr/bin/python3 + +from os import path +import json +from datetime import datetime +from io import BytesIO +from PIL import Image, UnidentifiedImageError +import frontmatter +from markdown import markdown +from bs4 import BeautifulSoup +from flask import render_template, Response, send_from_directory, request, make_response, Blueprint +from .storage import LocalStorage + +class ContentArea(Blueprint): + def __init__(self, directory: LocalStorage, *args, **kwargs): + self.md_directory = directory + + super().__init__(*args, **kwargs) + + self.add_app_template_filter(self.category_title, 'category_title') + self.add_app_template_filter(self.human_date, 'human_date') + self.add_app_template_filter(self.to_html, 'to_html') + + self.context_processor(self.processor) + + self.add_url_rule('/', view_func=self.projects) + self.add_url_rule('/category//', view_func=self.category) + self.add_url_rule('/', view_func=self.article) + self.add_url_rule('/image/', view_func=self.image) + + def processor(self) -> dict: + ''' Jninja processors ''' + def get_excerpt(post: frontmatter.Post) -> str: + html = markdown(post.content) + post_soup = BeautifulSoup(html, 'html.parser') + all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')]) + return ' '.join(all_text.split()[:200]) + return dict(get_excerpt=get_excerpt) + + def category_title(self, category_id: str) -> str: + ''' Jninja filter to get a category title by its ID ''' + with self.md_directory.open('categories.json') as categories_file: + categories = json.load(categories_file) + + return categories.get(category_id).get('title', '') + + def human_date(self, iso_date: str) -> str: + ''' Jninja filter to convert an ISO date to human readable. ''' + try: + return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y') + except ValueError: + return iso_date + + def to_html(self, content: str) -> str: + ''' Jninja filter to wrap markdown ''' + return markdown(content) + + def get_all_posts(self) -> list: + ''' Get all posts in the posts directory ''' + abs_paths = [x.path for x in self.md_directory.ls() if x.path.endswith('.md')] + posts = [] + for p in abs_paths: + with self.md_directory.open(p) as f: + posts.append(frontmatter.load(f)) + return posts + + def get_by_meta_key(self, key: str, value: str) -> list: + ''' Get posts by a metadata key value pair ''' + return [x for x in self.get_all_posts() if x.get(key) == value or isinstance(x.get(key, []), list) and value in x.get(key, [])] + + def projects(self) -> str: + ''' Load the projects page ''' + articles_to_return = sorted( + self.get_all_posts(), + key=lambda d: d.metadata.get('date'), + reverse=True + ) + + if len(articles_to_return) < 1: + return render_template('error.html', + error='There\'s nothing here... yet.', + description='I\'m still working on this page. Check back soon for some content.') + try: + with self.md_directory.open('categories.json') as categories_file: + categories = json.load(categories_file) + except FileNotFoundError: + return render_template('error.html', + error='There\'s nothing here... yet.', + description='I\'m still working on this page. Check back soon for some content.') + + return render_template('projects.html', + articles=articles_to_return, + all_categories=categories, + title='Projects', + page_title='Projects - ', + description='A selection of projects I\'ve been involved in') + + def category(self, category_id: str) -> str: + ''' Load the page for a given category ''' + try: + with self.md_directory.open('categories.json') as categories_file: + categories = json.load(categories_file) + the_category = categories.get(category_id) + except FileNotFoundError: + return render_template('error.html', + error='There\'s nothing here... yet.', + description='I\'m still working on this page. Check back soon for some content.') + + if the_category is None: + return Response(status=404) + + articles_to_return = sorted( + self.get_by_meta_key( + 'categories', category_id), + key=lambda d: d.metadata.get('date'), + reverse=True + ) + + if len(articles_to_return) < 1: + return render_template('error.html', + error='There\'s nothing here... yet.', + description='I\'m still working on this page. Check back soon for some content.') + + return render_template('projects.html', articles=articles_to_return, + title=the_category['title'], + description=the_category['long_description'], + page_title=f'{the_category["title"]} - ', + all_categories=categories, + current_category=category_id) + + def article(self, article_id: str) -> str: + ''' Load a single article ''' + articles = self.get_by_meta_key('id', article_id) + + if len(articles) == 0: + return Response(status=404) + if len(articles) > 1: + return Response(status=500) + + the_article = articles[0] + return render_template('article.html', post=markdown(the_article.content), + metadata=the_article.metadata, + page_title=f'{the_article.metadata["title"]} - ') + + def image(self, image_name: str) -> Response: + ''' Resize and return an image. ''' + w = int(request.args.get('w', 0)) + h = int(request.args.get('h', 0)) + + if w == 0 and h == 0: + return send_from_directory(self.md_directory.uri, path.join('images', image_name)) + try: + the_image = Image.open(path.join(self.md_directory.uri, 'images', image_name)) + except FileNotFoundError: + return Response(status=404) + except UnidentifiedImageError: + return send_from_directory(self.md_directory.uri, path.join('images', image_name)) + + max_width, max_height = the_image.size + + if (w >= max_width and h >= max_height): + return send_from_directory(self.md_directory.uri, path.join('images', image_name)) + + if path.exists(path.join('images', f'{w}-{h}-{image_name}')): + return send_from_directory(self.md_directory.uri, path.join('images', f'{w}-{h}-{image_name}')) + + req_size = [max_width, max_height] + if w > 0: + req_size[0] = w + if h > 0: + req_size[1] = h + + resized_img = BytesIO() + the_image.thumbnail(tuple(req_size)) + the_image.save(resized_img, format=the_image.format) + the_image.save(path.join(self.md_directory.uri, 'images', f'{w}-{h}-{image_name}'), the_image.format) + + response = make_response(resized_img.getvalue()) + response.headers.set('Content-Type', f'image/{the_image.format}') + return response diff --git a/src/jakecharman/storage.py b/src/jakecharman/storage.py new file mode 100644 index 0000000..b04f360 --- /dev/null +++ b/src/jakecharman/storage.py @@ -0,0 +1,28 @@ +#!/usr/bin/python3 + +import os + +class File(): + def __init__(self, storage: LocalStorage, path: str): # pylint: disable=used-before-assignment + self.path = path + self.storage = storage + + def open(self, *args, **kwargs): + ''' Open the file using the relevant storage ''' + return(self.storage.open(self.path, *args, **kwargs)) + +class LocalStorage(): + ''' Class to represent a location on a local file system ''' + def __init__(self, uri: str): + self.uri = uri + + def ls(self, path: str = '') -> list[File]: + ''' Return a listing of a directory ''' + if path.startswith('/'): + path = path[1:] + fullpath = os.path.join(self.uri, path) + return [File(self, os.path.join(fullpath, x)) for x in os.listdir(fullpath)] + + def open(self, path: str, *args, **kwargs): + ''' Open a file ''' + return open(os.path.join(self.uri, path), *args, **kwargs, encoding='utf8') diff --git a/src/templates/article.html b/src/jakecharman/templates/article.html similarity index 100% rename from src/templates/article.html rename to src/jakecharman/templates/article.html diff --git a/src/templates/contact.html b/src/jakecharman/templates/contact.html similarity index 100% rename from src/templates/contact.html rename to src/jakecharman/templates/contact.html diff --git a/src/templates/error.html b/src/jakecharman/templates/error.html similarity index 100% rename from src/templates/error.html rename to src/jakecharman/templates/error.html diff --git a/src/templates/footer.html b/src/jakecharman/templates/footer.html similarity index 100% rename from src/templates/footer.html rename to src/jakecharman/templates/footer.html diff --git a/src/templates/header.html b/src/jakecharman/templates/header.html similarity index 100% rename from src/templates/header.html rename to src/jakecharman/templates/header.html diff --git a/src/templates/index.html b/src/jakecharman/templates/index.html similarity index 100% rename from src/templates/index.html rename to src/jakecharman/templates/index.html diff --git a/src/templates/projects.html b/src/jakecharman/templates/projects.html similarity index 100% rename from src/templates/projects.html rename to src/jakecharman/templates/projects.html diff --git a/src/projects.py b/src/projects.py deleted file mode 100755 index bce9bba..0000000 --- a/src/projects.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/python3 - -from os import path -import json -from datetime import datetime -from io import BytesIO -from glob import glob -from PIL import Image, UnidentifiedImageError -import frontmatter -from markdown import markdown -from bs4 import BeautifulSoup -from flask import render_template, Response, send_from_directory, request, make_response -from index import app - -md_directory = path.join(path.realpath(path.dirname(__file__)), path.normpath('projects/')) - -@app.context_processor -def processor() -> dict: - ''' Jninja processors ''' - def get_excerpt(post: frontmatter.Post) -> str: - html = markdown(post.content) - post_soup = BeautifulSoup(html, 'html.parser') - all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')]) - return ' '.join(all_text.split()[:200]) - return dict(get_excerpt=get_excerpt) - -@app.template_filter('category_title') -def category_title(category_id: str) -> str: - ''' Jninja filter to get a category title by its ID ''' - with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file: - categories = json.load(categories_file) - - return categories.get(category_id).get('title', '') - -@app.template_filter('human_date') -def human_date(iso_date: str) -> str: - ''' Jninja filter to convert an ISO date to human readable. ''' - try: - return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y') - except ValueError: - return iso_date - -@app.template_filter('to_html') -def to_html(content: str) -> str: - ''' Jninja filter to wrap markdown ''' - return markdown(content) - -def get_all_posts(directory: str = md_directory) -> list: - ''' Get all posts in the posts directory ''' - abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')] - return [frontmatter.load(x) for x in abs_paths] - -def get_by_meta_key(directory: str, key: str, value: str) -> list: - ''' Get posts by a metadata key value pair ''' - return [x for x in get_all_posts(directory) if x.get(key) == value or type(x.get(key, [])) is list and value in x.get(key, [])] - -@app.route('/projects/') -def projects() -> str: - ''' Load the projects page ''' - articles_to_return = sorted( - get_all_posts( - md_directory), - key=lambda d: d.metadata.get('date'), - reverse=True - ) - - if len(articles_to_return) < 1: - return render_template('error.html', - error='There\'s nothing here... yet.', - description='I\'m still working on this page. Check back soon for some content.') - try: - with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file: - categories = json.load(categories_file) - except FileNotFoundError: - return render_template('error.html', - error='There\'s nothing here... yet.', - description='I\'m still working on this page. Check back soon for some content.') - - return render_template('projects.html', - articles=articles_to_return, - all_categories=categories, - title='Projects', - page_title='Projects - ', - description='A selection of projects I\'ve been involved in') - -@app.route('/projects/category//') -def category(category_id: str) -> str: - ''' Load the page for a given category ''' - try: - with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file: - categories = json.load(categories_file) - the_category = categories.get(category_id) - except FileNotFoundError: - return render_template('error.html', - error='There\'s nothing here... yet.', - description='I\'m still working on this page. Check back soon for some content.') - - if the_category is None: - return Response(status=404) - - articles_to_return = sorted( - get_by_meta_key( - md_directory, 'categories', category_id), - key=lambda d: d.metadata.get('date'), - reverse=True - ) - - if len(articles_to_return) < 1: - return render_template('error.html', - error='There\'s nothing here... yet.', - description='I\'m still working on this page. Check back soon for some content.') - - return render_template('projects.html', articles=articles_to_return, - title=the_category['title'], - description=the_category['long_description'], - page_title=f'{the_category["title"]} - ', - all_categories=categories, - current_category=category_id) - -@app.route('/projects/') -def article(article_id: str) -> str: - ''' Load a single article ''' - articles = get_by_meta_key(md_directory, 'id', article_id) - - if len(articles) == 0: - return Response(status=404) - if len(articles) > 1: - return Response(status=500) - - the_article = articles[0] - return render_template('article.html', post=markdown(the_article.content), - metadata=the_article.metadata, - page_title=f'{the_article.metadata["title"]} - ') - -@app.route('/projects/image/') -def image(image_name: str) -> Response: - ''' Resize and return an image. ''' - w = int(request.args.get('w', 0)) - h = int(request.args.get('h', 0)) - - if w == 0 and h == 0: - return send_from_directory(md_directory, path.join('images', image_name)) - try: - the_image = Image.open(path.join(md_directory, 'images', image_name)) - except FileNotFoundError: - return Response(status=404) - except UnidentifiedImageError: - return send_from_directory(md_directory, path.join('images', image_name)) - - max_width, max_height = the_image.size - - if (w >= max_width and h >= max_height): - return send_from_directory(md_directory, path.join('images', image_name)) - - if path.exists(path.join('images', f'{w}-{h}-{image_name}')): - return send_from_directory(md_directory, path.join('images', f'{w}-{h}-{image_name}')) - - req_size = [max_width, max_height] - if w > 0: - req_size[0] = w - if h > 0: - req_size[1] = h - - resized_img = BytesIO() - the_image.thumbnail(tuple(req_size)) - the_image.save(resized_img, format=the_image.format) - the_image.save(path.join(md_directory, 'images', f'{w}-{h}-{image_name}'), the_image.format) - - response = make_response(resized_img.getvalue()) - response.headers.set('Content-Type', f'image/{the_image.format}') - return response diff --git a/src/projects.wsgi b/src/projects.wsgi index 62fcc3c..1049b13 100755 --- a/src/projects.wsgi +++ b/src/projects.wsgi @@ -3,7 +3,7 @@ import sys sys.path.append('/var/www/jc') -from index import app as application +from jakecharman import app as application if __name__ == '__main__': - application.run(debug=True) \ No newline at end of file + application.run(debug=True) diff --git a/src/sitemap.py b/src/sitemap.py deleted file mode 100755 index c335319..0000000 --- a/src/sitemap.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/python3 - -import xml.etree.ElementTree as ET -import json -from flask import url_for, request, Response -from re import match -from index import app -from projects import get_all_posts - -def get_routes() -> list: - routes = [] - for rule in app.url_map.iter_rules(): - if 0 >= len(rule.arguments): - url = url_for(rule.endpoint, **(rule.defaults or {})) - routes.append(url) - return routes - -def get_build_date(): - try: - with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build: - build_json = json.load(build) - return build_json['date'] - except: - return '1970-01-01' - -@app.route('/sitemap.xml') -def sitemap(): - date = get_build_date() - root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9') - base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group() - base_url = base_url.replace('http://', 'https://') - for route in get_routes(): - url = ET.SubElement(root, 'url') - ET.SubElement(url, 'loc').text = base_url + route - ET.SubElement(url, 'lastmod').text = date - for article in get_all_posts(): - if 'link' in article.metadata: - continue - url = ET.SubElement(root, 'url') - ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}' - ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d') - - return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'}) \ No newline at end of file