ContactBridge

Log | Files | Refs | README

commit b5cdc1cf1d14614552cddc1f33c25a486442614e
parent 647dd248642fb92dd35b6770185618660d64d1a3
Author: William Lindholm <william_lindholm@outlook.com>
Date:   Sun, 12 Nov 2023 20:22:24 +0100

Improved API, both spam and inbox, now have all necessary endpoints.

Diffstat:
MWebAPI/__init__.py | 2++
MWebAPI/namespaces/contact.py | 16++++++++++++----
MWebAPI/namespaces/messages.py | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
MWebAPI/namespaces/spam.py | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MWebInterface/routes.py | 4++--
Mdatabase/models/messages.py | 16+++++++++++++++-
Mdatabase/models/spam.py | 16+++++++++++++++-
Mdatabase/repository.py | 3+--
Mstatic/css/main.css | 85+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Dstatic/html/tabletest.html | 54------------------------------------------------------
Mtemplates/component/ajaxtable.html | 5++---
11 files changed, 239 insertions(+), 138 deletions(-)

diff --git a/WebAPI/__init__.py b/WebAPI/__init__.py @@ -11,3 +11,5 @@ api = Api(blueprint) api.add_namespace(messages_ns) api.add_namespace(contact_ns) api.add_namespace(spam_ns) + + diff --git a/WebAPI/namespaces/contact.py b/WebAPI/namespaces/contact.py @@ -17,23 +17,26 @@ spam_model = SpamModel() mailer = Mailer() sd = SpamDetector() -message_create_model = contact_ns.model('Message', { +message_create_model = contact_ns.model('Contact', { 'name': fields.String(required=True, description='The senders name'), 'email': fields.String(required=True, description='The email address of the sender'), 'content': fields.String(required=True, description='The content of the message'), }) -@contact_ns.route('/contact') +@contact_ns.route('/') class ContactResource(Resource): @contact_ns.expect(message_create_model) def post(self): """Contact form endpoint""" try: - data, subject, content = prepare_data(request.form) + data, subject, content = prepare_data() inverted_relevance = calculate_relevance(data['content']) + except Exception as e: + return {'message': 'Invalid data'}, 400 + try: parameters = ( data['name'], data['email'], @@ -63,7 +66,12 @@ def calculate_relevance(content): return 1 -def prepare_data(form_data): +def prepare_data(): + if request.json: + form_data = request.json + else: + form_data = request.form + name = form_data['name'] email = form_data['email'] content = form_data['content'] diff --git a/WebAPI/namespaces/messages.py b/WebAPI/namespaces/messages.py @@ -1,15 +1,17 @@ -from flask import request, render_template, make_response +from flask import request, render_template, make_response, jsonify from flask_restx import Namespace, Resource, fields from MessageTagging.spam_detector import SpamDetector from WebAPI.utils.mailer import Mailer from database.models.messages import MessageModel +from database.models.spam import SpamModel from database.repository import DbRepository messages_ns = Namespace('messages', description='Message operations') db_repository = DbRepository() message_model = MessageModel() +spam_model = SpamModel() mailer = Mailer() sd = SpamDetector() @@ -23,10 +25,14 @@ message_read_model = messages_ns.model('Message', { 'relevance': fields.Float(readOnly=True, description='relevance, tries to set to low values for spam') }) + @messages_ns.route('/') class MessageList(Resource): @messages_ns.doc('get_messages') + @messages_ns.response(400, "sort_order must be either 'ASC' or 'DESC'") + @messages_ns.response(400, "Invalid sort_by field. Must be one of {sort fields}") + @messages_ns.response(406, "Invalid accept header. Must be one of {valid_accept_headers}, received: {accept_header}") @messages_ns.param('query', 'search query') @messages_ns.param('sort_by', 'Field to sort the messages by') @messages_ns.param('sort_order', 'Order to sort the messages (ASC or DESC)') @@ -42,6 +48,10 @@ class MessageList(Resource): sort_by = args.get('sort_by', 'id') # Default sort by 'id' sort_order = args.get('sort_order', 'ASC').upper() # Default sort order 'ASC' + messages, total_pages = message_model.get_page(page, page_size, sort=sort_by, sort_order=sort_order, + search=query) + accept_header = request.headers.get('Accept', '') + if sort_order not in ['ASC', 'DESC']: messages_ns.abort(400, "sort_order must be either 'ASC' or 'DESC'") @@ -49,22 +59,21 @@ class MessageList(Resource): if sort_by not in valid_sort_fields: messages_ns.abort(400, f"Invalid sort_by field. Must be one of {valid_sort_fields}") - messages, total_pages = message_model.get_page(page, page_size, sort=sort_by, sort_order=sort_order, - search=query) + valid_accept_headers = ['', 'text/html', 'application/json'] + if accept_header not in valid_accept_headers: + messages_ns.abort(406, + f"Invalid accept header. Must be one of {valid_accept_headers}, received: {accept_header}") - accept_header = request.headers.get('Accept', '') if 'text/html' in accept_header: response = make_response(render_template('component/messages_template.html', messages=messages)) response.headers['X-Total-Pages'] = total_pages response.headers['X-Current-Page'] = page return response else: - return { - 'messages': messages, - 'total_pages': total_pages, - 'current_page': page, - 'page_size': page_size - } + response = make_response(jsonify(messages)) + response.headers['X-Total-Pages'] = total_pages + response.headers['X-Current-Page'] = page + return response @messages_ns.route('/<int:id>') @@ -72,12 +81,58 @@ class MessageList(Resource): class Message(Resource): @messages_ns.marshal_with(message_read_model) def get(self, id): - return message_model.get(id) - messages_ns.abort(404, "Message not found") - + """Get a message""" + message = message_model.get(id) + if message: + return message, 200 + else: + messages_ns.abort(404, "Message not found") @messages_ns.response(204, 'Message deleted') + @messages_ns.response(404, 'Message not found') def delete(self, id): + """Delete a message""" + message = message_model.get(id) + if not message: + messages_ns.abort(404, "Message not found") + + try: + message_model.delete(id) + return make_response('', 204) + except Exception as e: + messages_ns.abort(500, "An internal error occurred") """Delete a message by id""" - message_model.delete(id) - return 'Deleted message', 204 + + +@messages_ns.response(404, 'Message not found') +@messages_ns.route('/flag/<int:id>') +class MessageList(Resource): + @messages_ns.response(204, 'Message flagged') + def post(self, id): + """Flag a message (moves it to spam)""" + message = message_model.get(id) + + if not message: + messages_ns.abort(404, 'Message not found') + + parameters = [ + message.get('name'), + message.get('email'), + message.get('subject'), + message.get('content'), + message.get('relevance') + ] + + if message: + try: + spam_model.create(parameters) + except Exception as e: + messages_ns.abort(500, 'An error occurred') + + try: + message_model.delete(message.get('id')) + except Exception as e: + spam_model.delete(message.get('id')) + messages_ns.abort(500, 'An error occurred') + + return '', 204 diff --git a/WebAPI/namespaces/spam.py b/WebAPI/namespaces/spam.py @@ -1,8 +1,9 @@ -from flask import request, render_template, make_response +from flask import request, render_template, make_response, jsonify from flask_restx import Namespace, Resource, fields from MessageTagging.spam_detector import SpamDetector from WebAPI.utils.mailer import Mailer +from database.models.messages import MessageModel from database.models.spam import SpamModel from database.repository import DbRepository @@ -10,10 +11,11 @@ spam_ns = Namespace('spam', description='Spam operations') db_repository = DbRepository() spam_model = SpamModel() +message_model = MessageModel() mailer = Mailer() sd = SpamDetector() -message_read_model = spam_ns.model('Message', { +spam_read_model = spam_ns.model('Spam', { 'id': fields.Integer(readOnly=True, description='The message unique identifier'), 'name': fields.String(required=True, description='The senders name'), 'email': fields.String(required=True, description='The email address of the sender'), @@ -26,7 +28,10 @@ message_read_model = spam_ns.model('Message', { @spam_ns.route('/') class MessageList(Resource): - @spam_ns.doc('get spam') + @spam_ns.doc('get_messages') + @spam_ns.response(400, "sort_order must be either 'ASC' or 'DESC'") + @spam_ns.response(400, "Invalid sort_by field. Must be one of {sort fields}") + @spam_ns.response(406, "Invalid accept header. Must be one of {valid_accept_headers}, received: {accept_header}") @spam_ns.param('query', 'search query') @spam_ns.param('sort_by', 'Field to sort the messages by') @spam_ns.param('sort_order', 'Order to sort the messages (ASC or DESC)') @@ -34,7 +39,7 @@ class MessageList(Resource): @spam_ns.param('page_size', 'The number of messages to retrieve per page') @spam_ns.produces(['application/json', 'text/html']) def get(self): - """Get (paginated) spam (HTML or JSON)""" + """Get (paginated) messages (HTML or JSON)""" args = request.args query = args.get('query', '', type=str) page = args.get('page', 1, type=int) @@ -42,6 +47,10 @@ class MessageList(Resource): sort_by = args.get('sort_by', 'id') # Default sort by 'id' sort_order = args.get('sort_order', 'ASC').upper() # Default sort order 'ASC' + messages, total_pages = spam_model.get_page(page, page_size, sort=sort_by, sort_order=sort_order, + search=query) + accept_header = request.headers.get('Accept', '') + if sort_order not in ['ASC', 'DESC']: spam_ns.abort(400, "sort_order must be either 'ASC' or 'DESC'") @@ -49,35 +58,79 @@ class MessageList(Resource): if sort_by not in valid_sort_fields: spam_ns.abort(400, f"Invalid sort_by field. Must be one of {valid_sort_fields}") - messages, total_pages = spam_model.get_page(page, page_size, sort=sort_by, sort_order=sort_order, - search=query) + valid_accept_headers = ['', 'text/html', 'application/json'] + if accept_header not in valid_accept_headers: + spam_ns.abort(406, + f"Invalid accept header. Must be one of {valid_accept_headers}, received: {accept_header}") - accept_header = request.headers.get('Accept', '') if 'text/html' in accept_header: response = make_response(render_template('component/messages_template.html', messages=messages)) response.headers['X-Total-Pages'] = total_pages response.headers['X-Current-Page'] = page return response else: - return { - 'messages': messages, - 'total_pages': total_pages, - 'current_page': page, - 'page_size': page_size - } + response = make_response(jsonify(messages)) + response.headers['X-Total-Pages'] = total_pages + response.headers['X-Current-Page'] = page + return response @spam_ns.route('/<int:id>') @spam_ns.response(404, 'Message not found') class Message(Resource): - @spam_ns.marshal_with(message_read_model) + @spam_ns.marshal_with(spam_read_model) def get(self, id): - return spam_ns.get(id) - messages_ns.abort(404, "Message not found") - + """Get a message""" + message = spam_model.get(id) + if message: + return message, 200 + else: + spam_ns.abort(404, "Message not found") @spam_ns.response(204, 'Message deleted') + @spam_ns.response(404, 'Message not found') def delete(self, id): + """Delete a message""" + message = spam_model.get(id) + if not message: + spam_ns.abort(404, "Message not found") + + try: + spam_model.delete(id) + return make_response('', 204) + except Exception as e: + spam_ns.abort(500, "An internal error occurred") """Delete a message by id""" - spam_ns.delete(id) - return 'Deleted message', 204 + + +@spam_ns.response(404, 'Message not found') +@spam_ns.route('/unflag/<int:id>') +class MessageList(Resource): + @spam_ns.response(204, 'Message unflagged') + def post(self, id): + """Unflag a message (moves it to inbox)""" + message = spam_model.get(id) + + if not message: + spam_ns.abort(404, 'Message not found') + + parameters = [ + message.get('name'), + message.get('email'), + message.get('subject'), + message.get('content'), + message.get('relevance') + ] + + if message: + try: + message_model.create(parameters) + except Exception as e: + spam_ns.abort(500, 'An error occurred') + try: + spam_model.delete(message.get('id')) + except Exception as e: + message_model.delete(message.get('id')) + spam_ns.abort(500, 'An error occurred') + + return '', 204 diff --git a/WebInterface/routes.py b/WebInterface/routes.py @@ -13,13 +13,13 @@ def home(): messages_context = { "api_endpoint": "messages", "id_prefix": "message-", - "headers": [("ID", "10%"), ("Relevance", "20%"), ("Name", "30%"), ("Content", "40%")] + "headers": [("ID", "10%"), ("Relevance", "10%"), ("Name", "25%"), ("Content", "55%"), ("", "0")] } spam_context = { "api_endpoint": "spam", "id_prefix": "spam-", - "headers": [("ID", "10%"), ("Relevance", "10%"), ("Name", "30%"), ("Content", "50%")] + "headers": [("ID", "10%"), ("Relevance", "10%"), ("Name", "25%"), ("Content", "55%"), ("", "0")] } return render_template('/page/home.html', page_title='Messages', messages_context=messages_context, spam_context=spam_context) diff --git a/database/models/messages.py b/database/models/messages.py @@ -61,7 +61,21 @@ class MessageModel: def get(self, message_id): query = 'SELECT * FROM messages WHERE id = ?' result = self.db_repository.execute_query(query, (message_id,), expect_result=True) - return result[0] if result else None + + if result: + message = result[0] + labeled_message = { + 'id': message[0], + 'name': message[1], + 'email': message[2], + 'subject': message[3], + 'content': message[4], + 'timestamp': message[5], + 'relevance': message[6] + } + return labeled_message + + return None def create(self, parameters): create_query = """ diff --git a/database/models/spam.py b/database/models/spam.py @@ -61,7 +61,21 @@ class SpamModel: def get(self, message_id): query = 'SELECT * FROM spam WHERE id = ?' result = self.db_repository.execute_query(query, (message_id,), expect_result=True) - return result[0] if result else None + + if result: + message = result[0] + labeled_message = { + 'id': message[0], + 'name': message[1], + 'email': message[2], + 'subject': message[3], + 'content': message[4], + 'timestamp': message[5], + 'relevance': message[6] + } + return labeled_message + + return None def create(self, parameters): create_query = """ diff --git a/database/repository.py b/database/repository.py @@ -27,4 +27,4 @@ class DbRepository: self.connection.executescript(sql_script) def close_connection(self): - self.connection.close() -\ No newline at end of file + self.connection.close() diff --git a/static/css/main.css b/static/css/main.css @@ -15,29 +15,36 @@ --content-margin: 150px; } -.user-view { - background: var(--gradient-start); - background: linear-gradient(268deg, var(--primary-color), var(--secondary-color)); -} - +/* Sidenav styles */ .sidenav { width: var(--sidenav-width); - box-shadow: 0px 3px 3px 3px #888888; + box-shadow: 0 3px 3px #888888; } +.sidenav-content { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +/* Header styles */ .header { font-family: var(--header-font-family); border-bottom: 1px solid var(--border-color); } -h1 { - font-size: var(--header-font-size); - line-height: var(--header-line-height); +h1, h1.mobile { background: -webkit-linear-gradient(var(--text-gradient-start), var(--text-gradient-end)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin: 0; padding: 0.2em 0; + line-height: var(--header-line-height); +} + +h1 { + font-size: var(--header-font-size); } h1.mobile { @@ -46,41 +53,27 @@ h1.mobile { flex-grow: 1; } -.truncate-td { - max-width: var(--truncate-max-width); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +/* User view styles */ +.user-view { + background: linear-gradient(268deg, var(--primary-color), var(--secondary-color)); } +/* Content styles */ .content { max-width: 100%; margin-left: var(--content-margin); - overflow: auto; + overflow: hidden; } -@media only screen and (max-width : 992px) { +@media only screen and (max-width: 992px) { .content { margin-left: 0; } } -.nav-center { - display: flex; - justify-content: space-between; - align-items: center; -} - -.sidenav-trigger { - color: black; - flex-shrink: 0; -} - -.sidenav-content { - display: flex; - flex-direction: column; - justify-content: space-between; - height: 100%; +/* Table and row styles */ +.row, table tr, table td { + margin: 0 !important; } .row { @@ -88,11 +81,6 @@ h1.mobile { max-width: 100%; } -table tr, table td { - margin: 0 !important; - -} - table td { max-width: 150px; overflow: hidden; @@ -100,6 +88,15 @@ table td { text-overflow: ellipsis; } +/* Truncation styles */ +.truncate-td { + max-width: var(--truncate-max-width); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Sorting header styles */ .sortable-header { cursor: pointer; transition: all 0.3s ease; @@ -110,6 +107,7 @@ table td { text-decoration: underline; } +/* Delete button styles */ .delete-button { color: red; transition: color 0.1s; @@ -118,4 +116,17 @@ table td { .delete-button:hover { color: darkred; +} + +/* Sidenav trigger styles */ +.sidenav-trigger { + color: black; + flex-shrink: 0; +} + +/* Flex container for navigation */ +.nav-center { + display: flex; + justify-content: space-between; + align-items: center; } \ No newline at end of file diff --git a/static/html/tabletest.html b/static/html/tabletest.html @@ -1,53 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <title>Messages</title> - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"> -</head> -<body> -<div class="container"> - <h2>Messages</h2> - - <div class="input-field"> - <input type="text" id="search-bar" placeholder="Search Messages..." onkeyup="searchData()"> - <div id="loading-indicator" style="display: none;">Loading...</div> - </div> - - <table class="highlight responsive-table"> - <thead id="table-headers"> - <tr> - <th data-field="id" data-order="asc">ID</th> - <th data-field="name" data-order="asc">Name</th> - <th data-field="email" data-order="asc">Email</th> - <th data-field="subject" data-order="asc">Subject</th> - <th data-field="timestamp" data-order="asc">Timestamp</th> - <th data-field="relevance" data-order="asc">Relevance</th> - </tr> - </thead> - <tbody id="table-body"> - <!-- Table data --> - </tbody> - </table> - - <ul class="pagination" id="pagination-container"> - <!-- Pagination --> - </ul> - - <select id="page-size" onchange="searchData('messages')"> - <option value="5">5</option> - <option value="10" selected>10</option> - <option value="25">25</option> - <option value="50">50</option> - </select> - -</div> - -<!-- Materialize JS --> -<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> - -<script>const API_ENDPOINT = '/api/messages/';</script> - -<script src="/static/js/pagination.js"></script> -</body> -</html> -\ No newline at end of file diff --git a/templates/component/ajaxtable.html b/templates/component/ajaxtable.html @@ -15,7 +15,7 @@ <div class="input-field col s10"> <input type="text" id="{{ context.id_prefix }}search-bar" placeholder="Search..."> </div> - <div class="input-field col s2 tooltipped" data-position="right" data-tooltip="Select page size"> + <div class="input-field col s2 tooltipped" data-position="left" data-tooltip="Select page size"> <select id="{{ context.id_prefix }}page-size"> <option value="5">5</option> <option value="10" selected>10</option> @@ -32,9 +32,8 @@ {% for header, width in context.headers %} <th {{context.id_prefix}}data-field="{{ header|lower }}" {{context.id_prefix}}data-order="asc" class="sortable-header" style="width: {{ width }};">{{ - header }} <i class="material-icons tiny">unfold_more</i></th> + header }}{% if header %}<i class="material-icons tiny">unfold_more</i>{% endif %}</th> {% endfor %} - <th></th> <!-- for the delete button column --> </tr> </thead> <tbody id="{{ context.id_prefix }}table-body">