Merge remote-tracking branch 'OpenBankProject/develop' into develop

This commit is contained in:
Hongwei 2023-11-28 12:40:17 +01:00
commit 3491f17e75
22 changed files with 20338 additions and 4408 deletions

View File

@ -15,7 +15,6 @@ import os
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse_lazy
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -77,6 +76,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
# 'django.middleware.cache.UpdateCacheMiddleware',
'csp.middleware.CSPMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
@ -123,7 +123,8 @@ TEMPLATES = [
'base.context_processors.api_tester_url',
'base.context_processors.portal_page',
'base.context_processors.logo_url',
'base.context_processors.override_css_url'
'base.context_processors.override_css_url',
'csp.context_processors.nonce'
],
},
},
@ -268,6 +269,16 @@ SHOW_API_TESTER = False
# Always save session$
SESSION_SAVE_EVERY_REQUEST = True
# Session Cookie Settings
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
SESSION_COOKIE_AGE = 300
# CSRF Cookie Settings
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SECURE = True
# Paths on API_HOST to OAuth
OAUTH_TOKEN_PATH = '/oauth/initiate'
OAUTH_AUTHORIZATION_PATH = '/oauth/authorize'
@ -308,6 +319,8 @@ CALLBACK_BASE_URL = ""
# Global
UNDEFINED = "<undefined>"
API_ROOT_KEY = "v500"
# Local settings can replace any value ABOVE
try:
from apimanager.local_settings import * # noqa
@ -330,3 +343,19 @@ if not OAUTH_CONSUMER_KEY:
raise ImproperlyConfigured('Missing settings for OAUTH_CONSUMER_KEY')
if not OAUTH_CONSUMER_SECRET:
raise ImproperlyConfigured('Missing settings for OAUTH_CONSUMER_SECRET')
#This has been moved to after API_HOST is imported so that connections to the API are allowed by the csp
# Content Security Policy - External Urls for scripts, styles, and images should be included here
#TODO these outside scripts should really just be loaded when we run "manage.py collectstatic"
# Or the whole static folder could be uploaded to github, this prevents API manager breaking when
# we run it on a server that may not connect to these sites
# Inline styles loaded by jsoneditor.min.js have been allowed by adding their hashes to CSP_STYLE_SRC
CSP_IMG_SRC = ("'self' data:", 'https://static.openbankproject.com')
CSP_STYLE_SRC = ("'self' 'sha256-z2a+NIknPDE7NIEqE1lfrnG39eWOhJXWsXHYGGNb5oU=' 'sha256-Dn0vMZLidJplZ4cSlBMg/F5aa7Vol9dBMHzBF4fGEtk=' 'sha256-sA0hymKbXmMTpnYi15KmDw4u6uRdLXqHyoYIaORFtjU=' 'sha256-jUuiwf3ITuJc/jfynxWHLwTZifHIlhddD8NPmmVBztk=' 'sha256-RqzjtXRBqP4i+ruV3IRuHFq6eGIACITqGbu05VSVXsI='", 'https://cdnjs.cloudflare.com', )
CSP_SCRIPT_SRC = ("'self' 'unsafe-eval' 'sha256-CAykt4V7LQN6lEkjV8hZQx0GV6LTZZGUvQDqamuUq2Q=' 'sha256-4Hr8ttnXaUA4A6o0hGi3NUGNP2Is3Ep0W+rvm+W7BAk=' 'sha256-GgQWQ4Ejk4g9XpAZJ4YxIgZDgp7CdQCmqjMOMh9hD2g=' 'sha256-05NIAwVBHkAzKcXTfkYqTnBPtkpX+AmQvM/raql3qo0='", 'http://code.jquery.com', 'https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/', 'https://cdnjs.cloudflare.com')
CSP_FONT_SRC = ("'self'", 'http://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/')
CSP_FRAME_ANCESTORS = ("'self'")
CSP_FORM_ACTION = ("'self'")
CSP_CONNECT_SRC = ("'self'", API_HOST)

View File

@ -31,6 +31,9 @@ footer a:hover, .footer a:focus {
color: #fff;
}
.footer-content-wrapper {
cursor:pointer;
}
.navbar-brand img {
height: 20px;
@ -74,6 +77,20 @@ footer a:hover, .footer a:focus {
margin-top: -6px;
}
.navbar-inner {
margin-left:15% !important;
}
.navbar-nav {
margin-left:8rem;
}
.obp-home-button {
position:absolute;
margin-left: -70px !important;
top:-5px;
}
/*.dropdown-menu > .active > a, .dropdown-menu > .active > a:hover, .dropdown-menu > .active > a:active {*/
/*background-color: #53c4ef;*/
/*}*/
@ -211,6 +228,12 @@ table.tablesorter thead tr .headerSortDown, table.tablesorter thead tr .headerSo
margin-left:5rem;
text-decoration: none !important;
}
.language-select > a {
color:#fff;
text-decoration: none !important;
}
#uk {
cursor:pointer;
}

1314
apimanager/base/static/css/jquery-ui.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
function addSeconds(date, seconds) {
let oldDate = date;
let addSeconds = seconds;
var newSeconds = oldDate + addSeconds;
//console.log(addSeconds);
date.setSeconds(date.getSeconds() + seconds);
return date;
}
export function showCountdownTimer() {
//TODO rather than display a timer the whole time in a span, make it only show when there are e.g. 30 seconds left.
// Maybe a whole page alert that the user will be logged out soon.
// Get current date and time
var now = new Date().getTime();
let distance = countDownDate - now;
// Output the result in an element with id="countdown-timer-span"
let elementId = ("countdown-timer-span");
document.getElementById(elementId).innerHTML = Math.floor(distance / 1000) + "s";
// If the count down is over release resources
if (distance < 0) {
destroyCountdownTimer();
}
}
// Set the date we're counting down to
let countDownDate = addSeconds(new Date(), 5);
let showTimerInterval = null;
export function destroyCountdownTimer() {
clearInterval(showTimerInterval);
}
export function resetCountdownTimer(seconds) {
destroyCountdownTimer(); // Destroy previous timer if any
countDownDate = addSeconds(new Date(), seconds); // Set the date we're counting down to
showTimerInterval = setInterval(showCountdownTimer, 1000); // Update the count down every 1 second
}

View File

@ -0,0 +1,107 @@
import * as countdownTimer from './inactivity-timer.js'
// holds the idle duration in ms (current value = 301 seconds)
var timeoutIntervalInMillis = 5 * 60 * 1000 + 1000;
// holds the timeout variables for easy destruction and reconstruction of the setTimeout hooks
var timeHook = null;
function initializeTimeHook() {
// this method has the purpose of creating our timehooks and scheduling the call to our logout function when the idle time has been reached
if (timeHook == null) {
timeHook = setTimeout( function () { destroyTimeHook(); logout()}.bind(this), timeoutIntervalInMillis);
}
}
function destroyTimeHook() {
// this method has the sole purpose of destroying any time hooks we might have created
clearTimeout(timeHook);
timeHook = null;
}
function resetTimeHook(event) {
// this method replaces the current time hook with a new time hook
destroyTimeHook();
initializeTimeHook();
countdownTimer.resetCountdownTimer(timeoutIntervalInMillis / 1000);
// show event type, element and coordinates of the click
// console.log(event.type + " at " + event.currentTarget);
// console.log("Coordinates: " + event.clientX + ":" + event.clientY);
console.log("Reset inactivity of a user");
}
function setupListeners() {
// here we setup the event listener for the mouse click operation
document.addEventListener("click", resetTimeHook);
document.addEventListener("mousemove", resetTimeHook);
document.addEventListener("mousedown", resetTimeHook);
document.addEventListener("keypress", resetTimeHook);
document.addEventListener("touchmove", resetTimeHook);
console.log("Listeners for user inactivity activated");
}
function destroyListeners() {
// here we destroy event listeners for the mouse click operation
document.removeEventListener("click", resetTimeHook);
document.removeEventListener("mousemove", resetTimeHook);
document.removeEventListener("mousedown", resetTimeHook);
document.removeEventListener("keypress", resetTimeHook);
document.removeEventListener("touchmove", resetTimeHook);
console.log("Listeners for user inactivity deactivated");
}
function logout() {
destroyListeners();
countdownTimer.destroyCountdownTimer();
console.log("Logging you out due to inactivity..");
const logoffButton = document.getElementById("logout");
logoffButton.click();
}
async function makeObpApiCall() {
let timeoutInSeconds;
try {
let obpApiHost = document.getElementById("api_home_link");
if(obpApiHost) {
obpApiHost = obpApiHost.href.split("?")[0];
}
console.log(obpApiHost);
const response = await fetch(`${obpApiHost}/obp/v5.1.0/ui/suggested-session-timeout`);
const json = await response.json();
if(json.timeout_in_seconds) {
timeoutInSeconds = json.timeout_in_seconds;
console.log(`Suggested value ${timeoutInSeconds} is used`);
} else {
timeoutInSeconds = 5 * 60 + 1; // Set default value to 301 seconds
console.log(`Default value ${timeoutInSeconds} is used`);
}
} catch (e) {
console.error(e);
timeoutInSeconds = 5 * 60 + 1; // Set default value to 301 seconds, even if the session timeout endpoint is not reachable for whatever reason
console.log(`Default value ${timeoutInSeconds} is used`);
}
return timeoutInSeconds;
}
async function getSuggestedSessionTimeout() {
if(!sessionStorage.getItem("suggested-session-timeout-in-seconds")) {
let timeoutInSeconds = await makeObpApiCall();
sessionStorage.setItem("suggested-session-timeout-in-seconds", timeoutInSeconds);
}
return sessionStorage.getItem("suggested-session-timeout-in-seconds") * 1000 + 1000; // We need timeout in millis
}
// self executing function to trigger the operation on page load
(async function () {
timeoutIntervalInMillis = await getSuggestedSessionTimeout(); // Try to get suggested value
const logoffButton = document.getElementById("countdown-timer-span");
if(logoffButton) {
// to prevent any lingering timeout handlers preventing memory leaks
destroyTimeHook();
// setup a fresh time hook
initializeTimeHook();
// setup initial event listeners
setupListeners();
// Reset countdown timer
countdownTimer.resetCountdownTimer(timeoutIntervalInMillis / 1000);
}
})();

18706
apimanager/base/static/js/jquery-ui.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -14,12 +14,13 @@
<link href="{% static 'css/base.css' %}" rel="stylesheet">
<link href="{% static 'css/jsoneditor.min.css' %}" rel="stylesheet">
<link href="{% static 'css/obpjsoneditor.css' %}" rel="stylesheet">
<link href="{% static 'css/jquery-ui.css' %}" rel="stylesheet">
{% block extracss %}{% endblock extracss %}
</head>
<body>
<nav class="navbar navbar-default navbar-fixed-top" role="navigation">
<div style="margin-left:15% !important;">
<div class="navbar-inner">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
@ -29,9 +30,9 @@
</button>
</div>
<div id="navbar" class="collapse navbar-collapse">
<ul class="nav navbar-nav" style="margin-left:8rem">
<li> <a href="{% url 'home' %}" style="position:absolute; margin-left: -70px !important; top:-5px"><img src="{{ logo_url }}" alt="brand"></a></li>
<li><a href="{{ API_PORTAL }}">{% trans "Home" %}</a></li>
<ul class="nav navbar-nav">
<li> <a class="obp-home-button" href="{% url 'home' %}"><img src="{{ logo_url }}" alt="brand"></a></li>
<li><a id="api_home_link" href="{{ API_PORTAL }}">{% trans "Home" %}</a></li>
{% url "consumers-index" as consumers_index_url %}
<li {% if consumers_index_url in request.path %} class="active" {% endif %}><a href="{{ consumers_index_url }}">{% trans "Consumers" %}</a></li>
{% url "entitlementrequests-index" as entitlementrequests_index_url %}
@ -104,10 +105,14 @@
{% endif %}
<li>
{% if user.is_authenticated %}
<p class="navbar-right button-select"><span id="navbar-login-username">{{API_USERNAME}}</span>&nbsp;&nbsp;<a href="/logout" class="btn btn-default">{% trans "Logout" %} </a></p>
<p class="navbar-right button-select">
<span id="navbar-login-username">{{API_USERNAME}}</span>&nbsp;&nbsp;
<a id="logout" href="/logout" class="btn btn-default">{% trans "Logout" %}</a>
<span id="countdown-timer-span"></span>
</p>
{% endif %}
</li>
<li class="language-select language_underline_format"><a style="color:#fff; text-decoration: none !important;">Language
<li class="language-select language_underline_format"><a>Language
<span id="gb">EN</span>
|
<span id="es">ES</span></a></li>
@ -128,13 +133,13 @@
{% endif %}
<div class="container" id="body-container">
{% block content %}{% endblock content %}
<div class="footer-content-wrapper" data-lift="WebUI.homePage" style="cursor:pointer">
<div class="footer-content-wrapper" data-lift="WebUI.homePage">
</div>
</div>
<footer>
<div class="container">
<p class="text-muted">
<a title="API ROOT" href="{{ API_ROOT }}">API ROOT: {{ API_ROOT }}</a> |
<a title="API host" href="{{ API_HOST }}">API host: {{ API_HOST }}</a> |
<a title="Open Bank Project" href="https://openbankproject.com?locale=en_GB">Open Bank Project</a> | Powered by <a title="TESOBE" href="http://tesobe.com">TESOBE</a> |
<small>Copyright &copy; 2016 - 2023</small>
</p>
@ -144,8 +149,10 @@
<script type="text/javascript" src="{% static 'js/jquery.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/bootstrap.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/jquery.tablesorter.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/jquery-ui.js' %}"></script>
<script src="{% static 'js/base.js' %}"></script>
<script type="text/javascript" src="{% static 'js/jsoneditor.min.js' %}"></script>
<script type="module" defer src="{% static 'js/inactivity.js' %}"></script>
{% block extrajs %}{% endblock extrajs %}
</body>

View File

@ -4,7 +4,7 @@ Views for base app
"""
from django.contrib import messages
from django.conf import settings
from django.views.generic import TemplateView
from django.views.generic import TemplateView, View
from django.shortcuts import render
from obp.forms import DirectLoginForm, GatewayLoginForm
from obp.api import API, APIError

View File

@ -187,8 +187,6 @@ class EnableDisableView(LoginRequiredMixin, RedirectView):
messages.success(self.request, self.success)
except APIError as err:
messages.error(self.request, err)
except APIError as err:
messages.error(self.request, err)
urlpath = self.request.POST.get('next', reverse('consumers-index'))
query = self.request.GET.urlencode()

View File

@ -6,3 +6,7 @@ input#id_kyc_status {
width: auto;
margin: -4px 0;
}
.displaynone {
display:none;
}

View File

@ -96,7 +96,7 @@
{{ form.date_of_birth_date }}
</div>
</div>
<div class="col-xs-12 col-sm-4" style="display:none">
<div class="col-xs-12 col-sm-4 displaynone">
{% if form.date_of_birth_time.errors %}<div class="alert alert-danger">{{ form.date_of_birth_time.errors }}</div>{% endif %}
<div class="form-group">
{{ form.date_of_birth_time.label_tag }}

View File

@ -9,3 +9,7 @@
#metrics #metrics-list ul {
margin-left: -25px;
}
.hiddenRow {
padding: 0 !important;
}

View File

@ -150,40 +150,70 @@
<div class="tab-content">
{% block tab_content %}
<div class="tab-pane active">
<div class="table-responsive">
<div class="table-responsive-md">
<table class="table table-hover tablesorter" aria-describedby="api list">
<thead>
<tr>
<th scope="col" class="sortless"></th>
<th scope="col">#</th>
<th scope="col">{% trans "Verb Select" %}</th>
<th scope="col">{% trans "URL" %}</th>
<th scope="col" class="sortless">{% trans "Verb Select" %}</th>
<th scope="col" class="col-2">{% trans "URL" %}</th>
<th scope="col">{% trans "Source IP" %}</th>
<th scope="col">{% trans "Target IP" %}</th>
<th scope="col">{% trans "Date" %}</th>
<th scope="col">{% trans "Duration(ms)" %}</th>
<th scope="col">{% trans "Details" %}</th>
</tr>
</thead>
<tbody>
{% for metric in metrics %}
<tr>
{% with i=forloop.counter|stringformat:"s" %}
{% with data_id="data"|add:i %}
{% with data_id_selector="#"|add:data_id %}
<tr data-toggle="collapse" data-target={{ data_id_selector }} class="accordion-toggle" data-toggle="tooltip" data-placement="top" title="Show Detail">
<td><button type="button" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-eye-open"></span></button></td>
<td>{{ forloop.counter }}</td>
<td>{{ metric.verb_selection }}</td>
<td>
{{ metric.url }}
</td>
<td>{{ metric.url }}</td>
<td>{{ metric.source_ip}}</td>
<td>{{ metric.target_ip }}</td>
<td>{{ metric.date|date:"Y-m-d H:i:s.u" }}</td>
<td>{{ metric.duration }}</td>
<td>
<ul>
<li>{% trans "User Name" %}: {{ metric.user_name }}</li>
<li>{% trans "User ID" %}: {{ metric.user_id }}</li>
<li>{% trans "Developer Email" %}: {{ metric.developer_email }}</li>
<li>{% trans "App Name" %}: {{ metric.app_name }}</li>
<li>{% trans "Consumer ID" %}: {{ metric.consumer_id }}</li>
<li>{% trans "Implemented by Partial Function" %}: {{ metric.implemented_by_partial_function }}</li>
<li>{% trans "Implemented In Version" %}: {{ metric.implemented_in_version }}</li>
</ul>
</td>
</tr>
<tr class="expand-child">
<td colspan="12" class="hiddenRow">
<div class="accordian-body collapse" id={{ data_id }}>
<table class="table table-hover">
<thead>
<tr class="info filteredRow">
<th scope="col">{% trans "Response Body" %}</th>
<th scope="col">{% trans "Details" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ metric.response_body }}</td>
<td>
<ul>
<li>{% trans "User Name" %}: {{ metric.user_name }}</li>
<li>{% trans "User ID" %}: {{ metric.user_id }}</li>
<li>{% trans "Developer Email" %}: {{ metric.developer_email }}</li>
<li>{% trans "App Name" %}: {{ metric.app_name }}</li>
<li>{% trans "Consumer ID" %}: {{ metric.consumer_id }}</li>
<li>{% trans "Implemented by Partial Function" %}: {{ metric.implemented_by_partial_function }}</li>
<li>{% trans "Implemented In Version" %}: {{ metric.implemented_in_version }}</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
{% endwith %}
{% endwith %}
{% endwith %}
{% endfor %}
</tbody>
</table>
@ -198,4 +228,4 @@
{% block extracss %}
<link href="{% static 'metrics/css/metrics.css' %}" rel="stylesheet">
{% endblock extracss %}
{% endblock extracss %}

View File

@ -43,7 +43,7 @@ class API(object):
self.start_session(session_data)
self.session_data = session_data
def call(self, method='GET', url='', payload=None, version=settings.API_ROOT['v500']):
def call(self, method='GET', url='', payload=None, version=settings.API_ROOT[settings.API_ROOT_KEY]):
"""Workhorse which actually calls the API"""
log(logging.INFO, '{} {}'.format(method, url))
if payload:
@ -64,7 +64,7 @@ class API(object):
response.execution_time = elapsed
return response
def get(self, urlpath='', version=settings.API_ROOT['v500']):
def get(self, urlpath='', version=settings.API_ROOT[settings.API_ROOT_KEY]):
"""
Gets data from the API
@ -77,7 +77,7 @@ class API(object):
else:
return response
def delete(self, urlpath, version=settings.API_ROOT['v500']):
def delete(self, urlpath, version=settings.API_ROOT[settings.API_ROOT_KEY]):
"""
Deletes data from the API
@ -87,7 +87,7 @@ class API(object):
response = self.call('DELETE', url)
return self.handle_response(response)
def post(self, urlpath, payload, version=settings.API_ROOT['v500']):
def post(self, urlpath, payload, version=settings.API_ROOT[settings.API_ROOT_KEY]):
"""
Posts data to given urlpath with given payload
@ -97,7 +97,7 @@ class API(object):
response = self.call('POST', url, payload)
return self.handle_response(response)
def put(self, urlpath, payload, version=settings.API_ROOT['v500']):
def put(self, urlpath, payload, version=settings.API_ROOT[settings.API_ROOT_KEY]):
"""
Puts data on given urlpath with given payload

File diff suppressed because it is too large Load Diff

View File

@ -133,5 +133,5 @@
{% block extracss %}
<link href="{% static 'users/css/users.css' %}" rel="stylesheet">
<link href="{% static 'users/css/users.css' %}" rel="stylesheet">
{% endblock extracss %}

View File

@ -9,5 +9,5 @@
<input type="number" class="form-control" name="limit" id="limit" placeholder="50" value="{{ limit }}">
</div>
<input type="submit" class="btn btn-default" value ='{% trans "Refresh" %} ' onclick="javascript: form.action='';"></input>
<input type="submit" class="btn btn-default" value ='{% trans "Export CSV" %} ' onclick="javascript: form.action='{% url 'export-csv' %}';"></input>
<input type="submit" class="btn btn-default" value ='{% trans "Export CSV" %} ' onclick="javascript: form.action='{% url 'export-csv-users' %}';"></input>
</form>

View File

@ -4,9 +4,10 @@ URLs for users app
"""
from django.conf.urls import url
from django.urls import path
from .views import IndexView, DetailView, MyDetailView, DeleteEntitlementView, InvitationView, UserStatusUpdateView, \
ExportCsvView
ExportCsvView, AutocompleteFieldView
urlpatterns = [
url(r'^all$',
@ -29,5 +30,5 @@ urlpatterns = [
name='user-status-update'),
url(r'^export_csv$',
ExportCsvView.as_view(),
name='export-csv')
name='export-csv-users'),
]

View File

@ -5,7 +5,7 @@ Views of users app
import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect, HttpResponse
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
from django.urls import reverse, reverse_lazy
from django.views.generic import FormView, TemplateView, View
@ -24,7 +24,6 @@ class FilterRoleName(BaseFilter):
]]
return filtered
class FilterEmail(BaseFilter):
"""Filter users by email address"""
filter_type = 'email'
@ -439,3 +438,22 @@ class ExportCsvView(LoginRequiredMixin, View):
writer.writerow([user['username'], user['user_id'], user['email'], user['provider_id'], user['provider'],
user['last_marketing_agreement_signed_date']])
return response
# This below code is not yet working, it is intended to provide a json list of results to feed to jquery-ui autocomplete feature
class AutocompleteFieldView(View):
"""Autocompletes a Field Form based on what endpoint the field is filtering"""
def autocomplete_form_field(self, request, *args, **kwargs):
api = API(self.request.session.get('obp'))
term = self.request.GET.get('term', '')
try:
urlpath = '/roles'
response = api.get(urlpath)
if 'code' in response and response['code'] >= 400:
messages.error(self.request, response['message'])
else:
suggestions = response.json()
return JsonResponse(suggestions, safe=False)
except APIError as err:
messages.error(self.request, err)
return [], []

View File

@ -3,9 +3,10 @@ Django==2.2.28
oauthlib==3.2.2
requests==2.27.1
requests-oauthlib==1.3.1
PyJWT==1.5.3
PyJWT==2.8.0
gunicorn==19.6.0
matplotlib
django-bootstrap-datepicker-plus==3.0.5
django-mathfilters
django-bootstrap3
django-csp