diff --git a/mxlive/lims/stats.py b/mxlive/lims/stats.py index 527189ae..17912068 100644 --- a/mxlive/lims/stats.py +++ b/mxlive/lims/stats.py @@ -15,6 +15,7 @@ from mxlive.lims.models import Data, Sample, Session, Project, AnalysisReport, Container, Shipment, ProjectType, SupportArea, UserFeedback, UserAreaFeedback, SupportRecord, FeedbackScale, DataType from mxlive.utils.functions import ShiftEnd, ShiftStart, ShiftIndex from mxlive.utils.misc import humanize_duration, natural_duration +from mxlive.utils.stats import make_table HOUR_SECONDS = 3600 SHIFT = getattr(settings, "HOURS_PER_SHIFT", 8) @@ -41,27 +42,6 @@ def get_data_periods(period='year'): return sorted(Data.objects.values_list(field, flat=True).order_by(field).distinct()) -def make_table(data, columns, rows, total_col=True, total_row=True): - ''' Converts a list of dictionaries into a list of lists ready for displaying as a table - data: list of dictionaries (one dictionary per column header) - columns: list of column headers to display in table, ordered the same as data - rows: list of row headers to display in table - ''' - header_row = [''] + columns - if total_col: header_row += ['All'] - table_data = [[str(r)] + [0] * (len(header_row) - 1) for r in rows] - for row in table_data: - for i, val in enumerate(data): - row[i+1] = val.get(row[0], 0) - if total_col: - row[-1] = sum(row[1:-1]) - if total_row: - footer_row = ['Total'] + [0] * (len(header_row) - 1) - for i in range(len(footer_row)-1): - footer_row[i+1] = sum([d[i+1] for d in table_data]) - return [header_row] + table_data + [footer_row] - - def get_time_scale(filters): period = filters.get('time_scale', 'year') if period == 'month': @@ -599,7 +579,7 @@ def parameter_summary(**filters): 'kind': 'histogram', 'data': { 'data': [ - {"x": row[0], "y": row[1]} for row in param_histograms[param] + {"x": float(row[0]), "y": float(row[1])} for row in param_histograms[param] ], }, 'style': 'col-12 col-md-6' diff --git a/mxlive/remote/middleware.py b/mxlive/remote/middleware.py index 4f1f345b..579c3685 100644 --- a/mxlive/remote/middleware.py +++ b/mxlive/remote/middleware.py @@ -1,3 +1,5 @@ +from cryptography.exceptions import InvalidSignature +from django.contrib.auth import get_user_model from django.http import HttpResponseRedirect from django.conf import settings from django.http import Http404 @@ -6,6 +8,11 @@ from ipaddress import ip_address from ipaddress import ip_network +from django.urls import resolve +from django.utils.deprecation import MiddlewareMixin +from rest_framework_simplejwt.authentication import JWTAuthentication, AuthenticationFailed +from mxlive.utils.signing import Signer + TRUSTED_URLS = getattr(settings, 'TRUSTED_URLS', []) TRUSTED_IPS = getattr(settings, 'TRUSTED_IPS', ['127.0.0.1/32']) @@ -13,14 +20,14 @@ class IPAddressList(list): def __init__(self, *ips): super().__init__() - self.extend([ip_network(ip) for ip in ips]) + self.extend([ip_network(ip, False) for ip in ips]) def __contains__(self, address): ip = ip_address(address) return any(ip in net for net in self) -class PermissionsMiddleware(object): +class PermissionsMiddleware(MiddlewareMixin): def process_request(self, request): if request.user.is_superuser: @@ -46,7 +53,7 @@ def get_client_address(request): return address and address.exploded or address -class TrustedAccessMiddleware(object): +class TrustedAccessMiddleware(MiddlewareMixin): """ Middleware to prevent access to the admin if the user IP isn't in the TRUSTED_IPS setting. @@ -66,3 +73,49 @@ def process_template_response(self, request, response): if response.context_data: response.context_data['internal_request'] = (client_address in trusted_addresses) return response + + +def get_v2_user(request): + url_data = resolve(request.path) + kwargs = url_data.kwargs + if 'username' in kwargs and 'signature' in kwargs: + user_model = get_user_model() + try: + user = user_model.objects.get(username=kwargs.get('username')) + except user_model.DoesNotExist: + return None + else: + if not user.key: + return None + + try: + signer = Signer(public=user.key) + value = signer.unsign(kwargs.get('signature')) + except InvalidSignature: + return None + else: + if value != kwargs.get('username'): + return None + return user + + +def get_v3_user(request): + authenticator = JWTAuthentication() + try: + user_data = authenticator.authenticate(request) + except AuthenticationFailed: + user_data = None + if user_data: + return user_data[0] + + +class APIAuthenticationMiddleware(MiddlewareMixin): + def process_request(self, request): + user = None + if request.path.startswith('/api/v2'): + user = get_v2_user(request) + elif request.path.startswith('/api/v3'): + user = get_v3_user(request) + + if user: + request.user = user diff --git a/mxlive/remote/urls.py b/mxlive/remote/urls.py index d25c884b..64e9cc7c 100644 --- a/mxlive/remote/urls.py +++ b/mxlive/remote/urls.py @@ -1,21 +1,23 @@ -from django.urls import re_path, path +from django.urls import path from . import views - -def keyed_url(regex, view, kwargs=None, name=None): - regex = r'(?P(?P[\w_-]+):.+)/' + regex[1:] - return re_path(regex, view, kwargs, name) - +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView +) urlpatterns = [ - re_path(r'^accesslist/$', views.AccessList.as_view()), - path('keys/', views.SSHKeys.as_view(), name='project-sshkeys'), + path('auth/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/verify/', TokenVerifyView.as_view(), name='token_verify'), - keyed_url(r'^data/(?P[\w_-]+)/$', views.AddData.as_view()), - keyed_url(r'^report/(?P[\w_-]+)/$', views.AddReport.as_view()), + path('accesslist/', views.AccessList.as_view()), + path('keys//', views.SSHKeys.as_view(), name='project-sshkeys'), - keyed_url(r'^project/$', views.UpdateUserKey.as_view(), name='project-update'), - keyed_url(r'^samples/(?P[\w_-]+)/$', views.ProjectSamples.as_view(), name='project-samples'), - keyed_url(r'^launch/(?P[\w_-]+)/(?P[\w_-]+)/$', views.LaunchSession.as_view(), name='session-launch'), - keyed_url(r'^close/(?P[\w_-]+)/(?P[\w_-]+)/$', views.CloseSession.as_view(), name='session-close'), + path('data//', views.AddData.as_view()), + path('report//', views.AddReport.as_view()), + path('samples//', views.ProjectSamples.as_view(), name='project-samples'), + path('session///start/', views.LaunchSession.as_view(), name='session-launch'), + path('session///close/', views.CloseSession.as_view(), name='session-close'), ] \ No newline at end of file diff --git a/mxlive/remote/urls_v2.py b/mxlive/remote/urls_v2.py new file mode 100644 index 00000000..d25c884b --- /dev/null +++ b/mxlive/remote/urls_v2.py @@ -0,0 +1,21 @@ +from django.urls import re_path, path +from . import views + + +def keyed_url(regex, view, kwargs=None, name=None): + regex = r'(?P(?P[\w_-]+):.+)/' + regex[1:] + return re_path(regex, view, kwargs, name) + + +urlpatterns = [ + re_path(r'^accesslist/$', views.AccessList.as_view()), + path('keys/', views.SSHKeys.as_view(), name='project-sshkeys'), + + keyed_url(r'^data/(?P[\w_-]+)/$', views.AddData.as_view()), + keyed_url(r'^report/(?P[\w_-]+)/$', views.AddReport.as_view()), + + keyed_url(r'^project/$', views.UpdateUserKey.as_view(), name='project-update'), + keyed_url(r'^samples/(?P[\w_-]+)/$', views.ProjectSamples.as_view(), name='project-samples'), + keyed_url(r'^launch/(?P[\w_-]+)/(?P[\w_-]+)/$', views.LaunchSession.as_view(), name='session-launch'), + keyed_url(r'^close/(?P[\w_-]+)/(?P[\w_-]+)/$', views.CloseSession.as_view(), name='session-close'), +] \ No newline at end of file diff --git a/mxlive/remote/views.py b/mxlive/remote/views.py index 93811524..1c490120 100644 --- a/mxlive/remote/views.py +++ b/mxlive/remote/views.py @@ -19,17 +19,18 @@ from django.utils.encoding import force_str from django.views.decorators.csrf import csrf_exempt from django.views.generic import View +from django.contrib.auth.mixins import LoginRequiredMixin from mxlive.utils.signing import Signer, InvalidSignature from mxlive.utils.data import parse_frames from .middleware import get_client_address -from ..lims.models import ActivityLog -from ..lims.models import Beamline, Dewar -from ..lims.models import Data, DataType -from ..lims.models import Project, Session -from ..lims.templatetags.converter import humanize_duration -from ..staff.models import UserList, RemoteConnection +from mxlive.lims.models import ActivityLog +from mxlive.lims.models import Beamline, Dewar +from mxlive.lims.models import Data, DataType +from mxlive.lims.models import Project, Session +from mxlive.lims.templatetags.converter import humanize_duration +from mxlive.staff.models import UserList, RemoteConnection if settings.LIMS_USE_SCHEDULE: HALF_SHIFT = int(getattr(settings, 'HOURS_PER_SHIFT', 8)/2) @@ -50,38 +51,17 @@ def make_secure_path(path): @method_decorator(csrf_exempt, name='dispatch') -class VerificationMixin(object): +class AuthenticationRequiredMixin(object): """ - Mixin to verify identity of user. - Requires URL parameters `username` and `signature` where the signature is a string that has been time-stamped and - signed using a private key, and can be unsigned using the public key stored with the user's MxLIVE User object. - - If the signature cannot be successfully unsigned, or the User does not exist, - the dispatch method will return a HttpResponseNotAllowed. + Mixin to verify that the user is logged-in without any redirects """ def dispatch(self, request, *args, **kwargs): - if not (kwargs.get('username') and kwargs.get('signature')): - return http.HttpResponseForbidden() + if hasattr(request, 'user') and request.user.is_authenticated: + return super().dispatch(request, *args, **kwargs) else: - User = get_user_model() - try: - user = User.objects.get(username=kwargs.get('username')) - except User.DoesNotExist: - return http.HttpResponseNotFound() - if not user.key: - return http.HttpResponseBadRequest() - else: - try: - signer = Signer(public=user.key) - value = signer.unsign(kwargs.get('signature')) - except InvalidSignature: - return http.HttpResponseForbidden() - - if value != kwargs.get('username'): - return http.HttpResponseForbidden() + return http.HttpResponseForbidden() - return super().dispatch(request, *args, **kwargs) @method_decorator(csrf_exempt, name='dispatch') @@ -95,7 +75,7 @@ class AccessList(View): def get(self, request, *args, **kwargs): - from ..staff.models import UserList + from mxlive.staff.models import UserList client_addr = get_client_address(request) userlist = UserList.objects.filter(address=client_addr, active=True).first() @@ -108,12 +88,12 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): client_addr = get_client_address(request) - userlist = UserList.objects.filter(address=client_addr, active=True).first() + user_list = UserList.objects.filter(address=client_addr, active=True).first() tz = timezone.get_current_timezone() errors = [] - if userlist: + if user_list: data = msgpack.loads(request.body) for conn in data: try: @@ -123,7 +103,7 @@ def post(self, request, *args, **kwargs): status = conn['status'] try: dt = tz.localize(datetime.strptime(conn['date'], "%Y-%m-%d %H:%M:%S")) - r, created = RemoteConnection.objects.get_or_create(name=conn['name'], userlist=userlist, user=project) + r, created = RemoteConnection.objects.get_or_create(name=conn['name'], userlist=user_list, user=project) r.status = status if created: r.created = dt @@ -133,7 +113,7 @@ def post(self, request, *args, **kwargs): except: pass - return JsonResponse(userlist.access_users(), safe=False) + return JsonResponse(user_list.access_users(), safe=False) else: return JsonResponse([], safe=False) @@ -150,7 +130,7 @@ class SSHKeys(View): def get(self, request, *args, **kwargs): client_addr = get_client_address(request) - userlist = UserList.objects.filter(address=client_addr, active=True).first() + user_list = UserList.objects.filter(address=client_addr, active=True).first() user = Project.objects.filter(username=self.kwargs.get('username')).first() msg = '' @@ -163,10 +143,7 @@ def get(self, request, *args, **kwargs): @method_decorator(csrf_exempt, name='dispatch') class UpdateUserKey(View): """ - API for adding a public key to an MxLIVE Project. This method will only be allowed if the signature can be verified, - and the User object does not already have a public key registered. - - :key: r'^(?P(?P):.+)/project/$' + API for adding a public key to an MxLIVE Project. """ def post(self, request, *args, **kwargs): @@ -192,23 +169,16 @@ def post(self, request, *args, **kwargs): return JsonResponse({}) -class LaunchSession(VerificationMixin, View): +class LaunchSession(AuthenticationRequiredMixin, View): """ Method to start an MxLIVE Session from the beamline. If a Session with the same name already exists, a new Stretch will be added to the Session. - - :key: r'^(?P(?P):.+)/launch/(?P)/(?P)/$' """ def post(self, request, *args, **kwargs): - - project_name = kwargs.get('username') beamline_name = kwargs.get('beamline') session_name = kwargs.get('session') - try: - project = Project.objects.get(username__exact=project_name) - except Project.DoesNotExist: - raise http.Http404("Project does not exist.") + project = request.user try: beamline = Beamline.objects.get(acronym__exact=beamline_name) @@ -247,22 +217,16 @@ def post(self, request, *args, **kwargs): return JsonResponse(session_info) -class CloseSession(VerificationMixin, View): +class CloseSession(AuthenticationRequiredMixin, View): """ Method to close an MxLIVE Session from the beamline. - :key: r'^(?P(?P):.+)/close/(?P)/(?P)/$' """ def post(self, request, *args, **kwargs): - - project_name = kwargs.get('username') beamline_name = kwargs.get('beamline') session_name = kwargs.get('session') - try: - project = Project.objects.get(username__exact=project_name) - except Project.DoesNotExist: - raise http.Http404("Project does not exist.") + project = request.user try: beamline = Beamline.objects.get(acronym__exact=beamline_name) @@ -302,22 +266,16 @@ def prep_sample(info, **kwargs): return sample -class ProjectSamples(VerificationMixin, View): +class ProjectSamples(AuthenticationRequiredMixin, View): """ :Return: Dictionary for each On-Site sample owned by the User and NOT loaded on another beamline. - - :key: r'^(?P(?P):.+)/samples/(?P)/$' """ def get(self, request, *args, **kwargs): - from ..lims.models import Project, Beamline, Container - project_name = kwargs.get('username') + from mxlive.lims.models import Project, Beamline, Container beamline_name = kwargs.get('beamline') - try: - project = Project.objects.get(username__exact=project_name) - except Project.DoesNotExist: - raise http.Http404("Project does not exist.") + project = request.user try: beamline = Beamline.objects.get(acronym=beamline_name) @@ -346,8 +304,7 @@ def get(self, request, *args, **kwargs): } - -class AddReport(VerificationMixin, View): +class AddReport(AuthenticationRequiredMixin, View): """ Method to add meta-data and JSON details about an AnalysisReport. @@ -360,20 +317,13 @@ class AddReport(VerificationMixin, View): :param beamline: Beamline__acronym :Return: {'id': < Created AnalysisReport.pk >} - - :key: r'^(?P(?P):.+)/report/(?P)/$' """ def post(self, request, *args, **kwargs): info = msgpack.loads(request.body, raw=False) - from ..lims.models import Project, Data, AnalysisReport - project_name = kwargs.get('username') - try: - project = Project.objects.get(username__exact=project_name) - except Project.DoesNotExist: - raise http.Http404("Project does not exist.") - + from mxlive.lims.models import Data, AnalysisReport + project = request.user try: data = Data.objects.filter(pk__in=info.get('data_id')) except: @@ -408,7 +358,7 @@ def post(self, request, *args, **kwargs): return JsonResponse({'id': report.pk}) -class AddData(VerificationMixin, View): +class AddData(AuthenticationRequiredMixin, View): """ Method to add meta-data about Data collected on the Beamline. @@ -430,19 +380,12 @@ class AddData(VerificationMixin, View): will be start_time + frames * exposure_time, otherwise it will be now :Return: {'id': < Created Data.pk >} - - :key: r'^(?P(?P):.+)/data/(?P)/$' """ def post(self, request, *args, **kwargs): info = msgpack.loads(request.body, raw=False) - - project_name = kwargs.get('username') beamline_name = kwargs.get('beamline') - try: - project = Project.objects.get(username__exact=project_name) - except Project.DoesNotExist: - raise http.Http404("Project does not exist.") + project = request.user try: beamline = Beamline.objects.get(acronym=beamline_name) @@ -497,4 +440,4 @@ def post(self, request, *args, **kwargs): ActivityLog.objects.log_activity(request, data, ActivityLog.TYPE.CREATE, "{} uploaded from {}".format( data.kind.name, beamline.acronym)) - return JsonResponse({'id': data.pk}) + return JsonResponse({'id': data.pk}) \ No newline at end of file diff --git a/mxlive/schedule/models.py b/mxlive/schedule/models.py index cdd865ea..518db720 100644 --- a/mxlive/schedule/models.py +++ b/mxlive/schedule/models.py @@ -62,7 +62,7 @@ def with_duration(self): return self.annotate( duration=Sum(F('end') - F('start')), shift_duration=ShiftEnd('end') - ShiftStart('start'), - shifts=Shifts(F('end') - F('start')) + shifts=Shifts(F('end') - F('start'), output_field=models.IntegerField()) ) diff --git a/mxlive/schedule/stats.py b/mxlive/schedule/stats.py index c271ed4d..6f877401 100644 --- a/mxlive/schedule/stats.py +++ b/mxlive/schedule/stats.py @@ -2,13 +2,12 @@ from collections import defaultdict from django.conf import settings -from django.db.models import Sum, Count, Avg, Case, When, Value, IntegerField - +from django.db.models import Sum, Count, Case, When, Value, IntegerField from memoize import memoize -from mxlive.schedule.models import Beamtime, Downtime, AccessType from mxlive.lims.models import Project, ProjectType -from mxlive.lims.stats import ColorScheme +from mxlive.lims.stats import ColorScheme, make_table +from mxlive.schedule.models import Beamtime, Downtime, AccessType from mxlive.utils.functions import Median HOUR_SECONDS = 3600 @@ -29,27 +28,6 @@ def get_beamtime_periods(field, objlist): return sorted(list(set(objlist.values_list(field, flat=True).distinct()))) -def make_table(data, columns, rows, total_col=True, total_row=True): - ''' Converts a list of dictionaries into a list of lists ready for displaying as a table - data: list of dictionaries (one dictionary per column header) - columns: list of column headers to display in table, ordered the same as data - rows: list of row headers to display in table - ''' - header_row = [''] + columns - if total_col: header_row += ['All'] - table_data = [[str(r)] + [0] * (len(header_row) - 1) for r in rows] - for row in table_data: - for i, val in enumerate(data): - row[i+1] = val.get(row[0], 0) - if total_col: - row[-1] = sum(row[1:-1]) - if total_row: - footer_row = ['Total'] + [0] * (len(header_row) - 1) - for i in range(len(footer_row)-1): - footer_row[i+1] = sum([d[i+1] for d in table_data]) - return [header_row] + table_data + [footer_row] - - def beamtime_stats(objlist, filters): period = filters.get('time_scale', 'year') annotations = {} @@ -67,7 +45,9 @@ def beamtime_stats(objlist, filters): field = 'start_cycle' periods = [1, 2] period_names = ['Jan-June', 'July-Dec'] - annotations['start_cycle'] = Case(When(start__quarter__lte=2, then=Value(1)), default=Value(2), output_field=IntegerField()) + annotations['start_cycle'] = Case( + When(start__quarter__lte=2, then=Value(1)), default=Value(2), output_field=IntegerField() + ) else: periods = get_beamtime_periods('start__year', objlist) period_names = periods @@ -129,14 +109,14 @@ def beamtime_access_stats(objlist, field, period, periods, period_names): 'style': 'row', 'content': [ { - 'title': 'Delivered shifts by {}'.format(period), + 'title': f'Delivered shifts by {period}', 'kind': 'table', 'data': access_shift_table, 'header': 'column row', 'style': 'col-12' }, { - 'title': 'Delivered shifts by {}'.format(period), + 'title': f'Delivered shifts by {period}', 'kind': 'columnchart', 'data': { 'x-label': period.title(), @@ -156,14 +136,14 @@ def beamtime_access_stats(objlist, field, period, periods, period_names): 'style': 'col-12 col-sm-6' }, { - 'title': 'Delivered visits by {}'.format(period), + 'title': f'Delivered visits by {period}', 'kind': 'table', 'data': access_visit_table, 'header': 'column row', 'style': 'col-12' }, { - 'title': 'Delivered visits by {}'.format(period), + 'title': f'Delivered visits by {period}', 'kind': 'columnchart', 'data': { 'x-label': period.title(), @@ -188,7 +168,8 @@ def beamtime_access_stats(objlist, field, period, periods, period_names): def beamtime_project_stats(objlist, field, period, periods, period_names): # Delivered beamtime by project type - project_colors = {p.name: ColorScheme.Live8[i+1] for i, p in enumerate(list(ProjectType.objects.order_by('name')))} + project_colors = {p.name: ColorScheme.Live8[i + 1] for i, p in + enumerate(list(ProjectType.objects.order_by('name')))} project_colors['None'] = ColorScheme.Live8[0] project_names = list(project_colors.keys()) @@ -210,14 +191,14 @@ def beamtime_project_stats(objlist, field, period, periods, period_names): 'title': 'Beamtime by Project Type', 'style': 'row', 'content': [{ - 'title': 'Delivered shifts by {}'.format(period), - 'kind': 'table', - 'data': project_shift_table, - 'header': 'column row', - 'style': 'col-12' - }, + 'title': f'Delivered shifts by {period}', + 'kind': 'table', + 'data': project_shift_table, + 'header': 'column row', + 'style': 'col-12' + }, { - 'title': 'Delivered shifts by {}'.format(period), + 'title': f'Delivered shifts by {period}', 'kind': 'columnchart', 'data': { 'x-label': period.title(), @@ -255,17 +236,27 @@ def downtime_stats(objlist, filters, annotations, field, period, periods, period downtime_summary = "" for scope in Downtime.SCOPE_CHOICES: downtime_summary += ' {}'.format(scope[1]) + '\n\n' - downtime_summary += ' Unspecified ({}) '.format(sum([s['shifts'] for s in downtime_info.filter( - scope=scope[0]).filter(comments='').values(field).annotate(shifts=Sum('shifts'))])) - downtime_summary += ' \t'.join(['{} ({})'.format(s['comments'], s['shifts']) for s in downtime_info.filter( - scope=scope[0]).exclude(comments='').values(field, 'comments').annotate(shifts=Sum('shifts'))]) + '\n\n' + downtime_summary += ' Unspecified ({}) '.format( + sum( + [s['shifts'] for s in downtime_info.filter( + scope=scope[0] + ).filter(comments='').values(field).annotate(shifts=Sum('shifts'))] + ) + ) + downtime_summary += ' \t'.join( + ['{} ({})'.format(s['comments'], s['shifts']) for s in downtime_info.filter( + scope=scope[0] + ).exclude(comments='').values(field, 'comments').annotate(shifts=Sum('shifts'))] + ) + '\n\n' downtime_table_data = [] for i, per in enumerate(periods): - downtime_table_data.append({ - **{period.title(): period_names[i], 'Cancelled Shifts': cancelled_info.get(per, 0)}, - **downtime_data[per] - }) + downtime_table_data.append( + { + **{period.title(): period_names[i], 'Cancelled Shifts': cancelled_info.get(per, 0)}, + **downtime_data[per] + } + ) for row in downtime_table_data: for k in downtime_scopes: @@ -304,19 +295,22 @@ def downtime_stats(objlist, filters, annotations, field, period, periods, period def beamtime_community_stats(objlist, field, period, periods, period_names): - project_colors = {p.name: ColorScheme.Live8[i + 1] for i, p in enumerate(list(ProjectType.objects.order_by('name')))} + project_colors = {p.name: ColorScheme.Live8[i + 1] for i, p in + enumerate(list(ProjectType.objects.order_by('name')))} project_colors['None'] = ColorScheme.Live8[0] project_names = list(project_colors.keys()) # Distinct Users - distinct_users_info = objlist.order_by().exclude(project__isnull=True).values(field, 'project__kind__name').annotate(Count('project', distinct=True)) + distinct_users_info = objlist.order_by().exclude(project__isnull=True).values( + field, 'project__kind__name' + ).annotate(Count('project', distinct=True)) distinct_users = [] for i, per in enumerate(periods): series = { - **{ period.title(): period_names[i] }, - **{ str(d['project__kind__name']): d['project__count'] - for d in distinct_users_info if d[field] == per - } + **{period.title(): period_names[i]}, + **{str(d['project__kind__name']): d['project__count'] + for d in distinct_users_info if d[field] == per + } } for project_type in project_names - series.keys(): series[str(project_type)] = 0 @@ -327,7 +321,8 @@ def beamtime_community_stats(objlist, field, period, periods, period_names): beamtime_usage_median = objlist.values('project').annotate(beamtime=Sum('shifts')).aggregate(Median('beamtime')) beamtime_usage_data = { period_names[i]: objlist.filter(**{field: per}).values('project').annotate(beamtime=Sum('shifts')).aggregate( - Median('beamtime'))['beamtime__median'] + Median('beamtime') + )['beamtime__median'] for i, per in enumerate(periods) } beamtime_usage_data = {k: v for k, v in beamtime_usage_data.items() if v} @@ -342,10 +337,14 @@ def beamtime_community_stats(objlist, field, period, periods, period_names): hindex = h_indices(Project.objects.filter(pk__in=objlist.values_list('project', flat=True).distinct())) hindex_data = [] for i, per in enumerate(periods): - hindex_data.append({ - period.title(): period_names[i], - 'H-Index': active_users.get(per) and sum([hindex[u] for u in active_users[per]])/len(active_users[per]) or 0 - }) + hindex_data.append( + { + period.title(): period_names[i], + 'H-Index': active_users.get(per) and sum([hindex[u] for u in active_users[per]]) / len( + active_users[per] + ) or 0 + } + ) return { 'title': 'User Community', @@ -365,13 +364,17 @@ def beamtime_community_stats(objlist, field, period, periods, period_names): 'x-label': period.title(), 'stack': [project_names], 'data': [ - {**data, - **{"Median of Beamtime Usage": beamtime_usage_data.get(data[period.title()], 0)}} for data in distinct_users + { + **data, + **{"Median of Beamtime Usage": beamtime_usage_data.get(data[period.title()], 0)} + } for data in distinct_users ], 'colors': project_colors, 'line': "Median of Beamtime Usage", }, - 'notes': 'Overall Median of Beamtime Usage: {} shifts'.format(beamtime_usage_median['beamtime__median']), + 'notes': 'Overall Median of Beamtime Usage: {} shifts'.format( + beamtime_usage_median['beamtime__median'] + ), 'style': 'col-12 col-md-6' }, PUBLICATIONS and { @@ -387,4 +390,4 @@ def beamtime_community_stats(objlist, field, period, periods, period_names): 'style': 'col-12 col-md-6' } or {} ] - } \ No newline at end of file + } diff --git a/mxlive/settings.py b/mxlive/settings.py index a7f9809b..bdaa0983 100644 --- a/mxlive/settings.py +++ b/mxlive/settings.py @@ -12,6 +12,7 @@ import os import sys +from datetime import timedelta # Build paths inside the project like this: os.path.join(BASE_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -57,6 +58,8 @@ 'mxlive.remote', 'crispy_forms', 'crispy_bootstrap4', + 'rest_framework', + 'rest_framework_simplejwt.token_blacklist', # 'debug_toolbar', ] @@ -69,8 +72,9 @@ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', - # 'mxlive.remote.middleware.TrustedAccessMiddleware', + 'mxlive.remote.middleware.TrustedAccessMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'mxlive.remote.middleware.APIAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -127,7 +131,7 @@ AUTHENTICATION_BACKENDS = [ 'django_python3_ldap.auth.LDAPBackend', - 'django.contrib.auth.backends.ModelBackend' + 'django.contrib.auth.backends.ModelBackend', ] AUTH_USER_MODEL = 'lims.Project' @@ -232,6 +236,47 @@ def clean_user(user, data): 'debug_toolbar.panels.profiling.ProfilingPanel', ] +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=30), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=360), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + + 'ALGORITHM': 'HS256', + + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + 'JWK_URL': None, + 'LEEWAY': 0, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_X_ACCESS_TOKEN', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule', + + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + 'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser', + + 'JTI_CLAIM': 'jti', + + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), +} + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), +} + try: from local.settings import * except ImportError: @@ -241,4 +286,4 @@ def clean_user(user, data): INSTALLED_APPS.extend(['mxlive.schedule', 'colorfield']) if LIMS_USE_PUBLICATIONS: - INSTALLED_APPS.extend(['mxlive.publications']) \ No newline at end of file + INSTALLED_APPS.extend(['mxlive.publications']) diff --git a/mxlive/urls.py b/mxlive/urls.py index a95bfbc8..60b93c2f 100644 --- a/mxlive/urls.py +++ b/mxlive/urls.py @@ -1,4 +1,4 @@ -from django.urls import path, re_path, include +from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from django.contrib import admin @@ -8,22 +8,23 @@ from mxlive.lims.views import ProjectDetail, ProxyView urlpatterns = [ - re_path(r'^$', login_required(ProjectDetail.as_view()), name='dashboard'), + path('', login_required(ProjectDetail.as_view()), name='dashboard'), path('admin/', admin.site.urls, name='admin'), - re_path(r'^staff/', include('mxlive.staff.urls')), - re_path(r'^users/', include('mxlive.lims.urls')), - re_path(r'^files/(?P
[^/]+)/(?P.*)$', ProxyView.as_view(), name='files-proxy'), + path('staff/', include('mxlive.staff.urls')), + path('users/', include('mxlive.lims.urls')), + path('files//', ProxyView.as_view(), name='files-proxy'), path('accounts/login/', LoginView.as_view(template_name='login.html'), name="mxlive-login"), path('accounts/logout/', LogoutView.as_view(), name="mxlive-logout"), - re_path(r'^api/v2/', include('mxlive.remote.urls')), + path('api/v2/', include('mxlive.remote.urls_v2')), + path('api/v3/', include('mxlive.remote.urls')), ] if settings.LIMS_USE_SCHEDULE: - urlpatterns += [re_path(r'^calendar/', include('mxlive.schedule.urls'))] + urlpatterns += [path('calendar/', include('mxlive.schedule.urls'))] if settings.LIMS_USE_PUBLICATIONS: - urlpatterns += [re_path(r'^publications/', include('mxlive.publications.urls'))] + urlpatterns += [path('publications/', include('mxlive.publications.urls'))] if settings.DEBUG: # import debug_toolbar @@ -32,4 +33,5 @@ # path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns urlpatterns += staticfiles_urlpatterns() - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + diff --git a/mxlive/utils/stats.py b/mxlive/utils/stats.py index 80d7ff19..d7405216 100644 --- a/mxlive/utils/stats.py +++ b/mxlive/utils/stats.py @@ -100,3 +100,43 @@ def generic_stats(objlist, fields, date_field=None): ] } return stats + + +def make_table(data, columns: list, rows: list, total_col=True, total_row=True, strings=False): + """ + Converts a list of dictionaries into a list of lists ready for displaying as a table + :param data: list of dictionaries (one dictionary per column header) + :param columns: list of column headers to display in table, ordered the same as data + :param rows: list of row headers to display in table + :param total_col: include a total column + :param total_row: include a total row + :param strings: convert all cells to strings + :return: list of lists + """ + + headers = [''] + columns + table_data = [headers] + [ + [key] + [item.get(key, 0) for item in data] + for key in rows + ] + + if total_row: + table_data.append( + ['Total'] + [ + sum([row[i] for row in table_data[1:]]) + for i in range(1, len(headers)) + ] + ) + + if total_col: + table_data[0].append('All') + for row in table_data[1:]: + row.append(sum(row[1:])) + + if strings: + table_data = [ + [f'{item}' for item in row] + for row in table_data + ] + + return table_data diff --git a/requirements.txt b/requirements.txt index 33b3c0e7..0b513af7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,42 +1,44 @@ Click -Django==4.2.17 +Django==4.2.18 Markdown asgiref asn1crypto certifi cffi chardet -cryptography -django-colorfield -django-crispy-forms +cryptography==44.0.0 +django-colorfield==0.11.0 +django-crispy-forms==2.3 django-debug-toolbar django-debug-toolbar-request-history -django-formtools +django-formtools==2.5.1 django-itemlist==0.2.13 django-memoize -django-model-utils -django-proxy +django-model-utils==5.0.0 +django-proxy==1.3.0 django-python3-ldap +djangorestframework==3.15.2 +djangorestframework-simplejwt==5.4.0 crispy-bootstrap4 -geopy -habanero +geopy==2.4.1 +habanero==2.0.0 idna jsonfield2 -ldap3 -msgpack -numpy +ldap3==2.9.1 +msgpack==1.1.0 +numpy==2.2.1 pip-chill psycopg2-binary pyasn1 pycparser -python-dateutil +python-dateutil==2.9.0.post0 pymemcache~=4.0.0 -pytz +pytz==2024.2 tzdata -requests +requests==2.32.3 setuptools six sqlparse -timezonefinder +timezonefinder==6.5.7 urllib3 wkhtmltopdf \ No newline at end of file