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:
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">