From 35d3e80e31a3eb11f514ac9fca19827a78ca478b Mon Sep 17 00:00:00 2001 From: "Michael J. Stealey" Date: Fri, 28 Oct 2022 17:47:04 -0400 Subject: [PATCH] [#18, #27] user messages, email, admin user --- compose/docker-compose.yml.docker | 1 + docker-entrypoint.sh | 10 +- portal/apps/experiment_files/views.py | 5 +- .../apps/experiments/api/experiment_utils.py | 10 +- portal/apps/experiments/views.py | 4 +- portal/apps/profiles/views.py | 39 +++- portal/apps/resources/views.py | 5 +- portal/apps/user_messages/__init__.py | 0 portal/apps/user_messages/admin.py | 1 + portal/apps/user_messages/api/serializers.py | 54 +++++ portal/apps/user_messages/api/viewsets.py | 190 ++++++++++++++++++ portal/apps/user_messages/apps.py | 6 + portal/apps/user_messages/models.py | 51 +++++ portal/apps/user_messages/tests.py | 1 + portal/apps/user_messages/urls.py | 12 ++ portal/apps/user_messages/user_messages.py | 163 +++++++++++++++ portal/apps/user_messages/views.py | 114 +++++++++++ portal/apps/user_requests/api/viewsets.py | 7 +- portal/apps/user_requests/urls.py | 5 - .../commands/create_aerpaw_admin_user.py | 49 +++++ portal/apps/users/oidc_users.py | 5 + portal/server/settings.py | 20 ++ portal/server/urls.py | 5 + portal/templates/portal/navbar.html | 2 +- portal/templates/profiles/profile.html | 35 +++- portal/templates/rest_framework/login.html | 27 ++- .../user_messages/user_message_detail.html | 83 ++++++++ .../user_messages/user_message_list.html | 126 ++++++++++++ .../user_requests/user_role_request_list.html | 82 ++++---- run_server.sh | 1 + 30 files changed, 1029 insertions(+), 84 deletions(-) create mode 100644 portal/apps/user_messages/__init__.py create mode 100644 portal/apps/user_messages/admin.py create mode 100644 portal/apps/user_messages/api/serializers.py create mode 100644 portal/apps/user_messages/api/viewsets.py create mode 100644 portal/apps/user_messages/apps.py create mode 100644 portal/apps/user_messages/models.py create mode 100644 portal/apps/user_messages/tests.py create mode 100644 portal/apps/user_messages/urls.py create mode 100644 portal/apps/user_messages/user_messages.py create mode 100644 portal/apps/user_messages/views.py create mode 100644 portal/apps/users/management/commands/create_aerpaw_admin_user.py create mode 100644 portal/templates/user_messages/user_message_detail.html create mode 100644 portal/templates/user_messages/user_message_list.html diff --git a/compose/docker-compose.yml.docker b/compose/docker-compose.yml.docker index 6a13fc6..cb21fa1 100644 --- a/compose/docker-compose.yml.docker +++ b/compose/docker-compose.yml.docker @@ -59,6 +59,7 @@ services: - UWSGI_UID=${UWSGI_UID} - UWSGI_GID=${UWSGI_GID} - LOAD_FIXTURES=${LOAD_FIXTURES:-0} + - MAKE_MIGRATIONS=${MAKE_MIGRATIONS:-0} restart: unless-stopped networks: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8c84441..4e5d547 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -15,10 +15,14 @@ done >&2 echo "Postgres is up - continuing" -if [[ "${LOAD_FIXTURES}" -eq 1 ]]; then - ./run_server.sh --mode docker --load-fixtures +if [ "${LOAD_FIXTURES}" -eq 1 ] && [ "${MAKE_MIGRATIONS}" -eq 1 ]; then + ./run_server.sh --run-mode docker --load-fixtures --make-migrations +elif [ "${LOAD_FIXTURES}" -eq 1 ]; then + ./run_server.sh --run-mode docker --load-fixtures +elif [ "${MAKE_MIGRATIONS}" -eq 1 ]; then + ./run_server.sh --run-mode docker --make-migrations else - ./run_server.sh --mode docker + ./run_server.sh --run-mode docker fi exec "$@" diff --git a/portal/apps/experiment_files/views.py b/portal/apps/experiment_files/views.py index 40ee0a5..2416d8d 100644 --- a/portal/apps/experiment_files/views.py +++ b/portal/apps/experiment_files/views.py @@ -59,11 +59,12 @@ def experiment_file_list(request): max_range = int(current_page - 1) * int(REST_FRAMEWORK['PAGE_SIZE']) + int(REST_FRAMEWORK['PAGE_SIZE']) if max_range > count: max_range = count - + else: + experiment_files = {} item_range = '{0} - {1}'.format(str(min_range), str(max_range)) except Exception as exc: message = exc - experiment_files = None + experiment_files = {} item_range = None next_page = None prev_page = None diff --git a/portal/apps/experiments/api/experiment_utils.py b/portal/apps/experiments/api/experiment_utils.py index 67ab66f..a8c4e8c 100644 --- a/portal/apps/experiments/api/experiment_utils.py +++ b/portal/apps/experiments/api/experiment_utils.py @@ -75,10 +75,11 @@ def active_development_to_saving_development(request, experiment: AerpawExperime else: # PRODUCTION: if exit_development: - command = "sudo python3 /home/aerpawops/AERPAW-Dev/workflow-scripts/apcf_saveexit_ve_exp.py {0} save-and-exit".format(experiment.id) + command = "sudo python3 /home/aerpawops/AERPAW-Dev/workflow-scripts/apcf_saveexit_ve_exp.py {0} save-and-exit".format( + experiment.id) else: - command = "sudo python3 /home/aerpawops/AERPAW-Dev/workflow-scripts/apcf_saveexit_ve_exp.py {0} save".format(experiment.id) - + command = "sudo python3 /home/aerpawops/AERPAW-Dev/workflow-scripts/apcf_saveexit_ve_exp.py {0} save".format( + experiment.id) ssh_thread = threading.Thread(target=saving_development, args=(request, experiment, command, exit_development)) ssh_thread.start() @@ -923,7 +924,8 @@ def saving_development(request, experiment: AerpawExperiment, command: str, exit detail="SaveError: something occurred during the save for /experiments/{0}/state".format(experiment.id)) else: raise NotFound( - detail="SaveError: unable to deploy active_development for /experiments/{0}/state".format(experiment.id)) + detail="SaveError: unable to deploy active_development for /experiments/{0}/state".format( + experiment.id)) def saving_sandbox(request, experiment: AerpawExperiment, command: str, exit_sandbox: bool): diff --git a/portal/apps/experiments/views.py b/portal/apps/experiments/views.py index de119dd..d10a921 100644 --- a/portal/apps/experiments/views.py +++ b/portal/apps/experiments/views.py @@ -82,10 +82,12 @@ def experiment_list(request): max_range = int(current_page - 1) * int(REST_FRAMEWORK['PAGE_SIZE']) + int(REST_FRAMEWORK['PAGE_SIZE']) if max_range > count: max_range = count + else: + experiments = {} item_range = '{0} - {1}'.format(str(min_range), str(max_range)) except Exception as exc: message = exc - experiments = None + experiments = {} item_range = None next_page = None prev_page = None diff --git a/portal/apps/profiles/views.py b/portal/apps/profiles/views.py index efa5b30..7fe1598 100644 --- a/portal/apps/profiles/views.py +++ b/portal/apps/profiles/views.py @@ -2,9 +2,10 @@ from django.http import HttpRequest from django.shortcuts import get_object_or_404, render from django.views.decorators.csrf import csrf_exempt -from rest_framework.request import Request +from rest_framework.request import QueryDict, Request from portal.apps.credentials.api.viewsets import CredentialViewSet +from portal.apps.user_messages.api.viewsets import UserMessageViewSet from portal.apps.user_requests.api.viewsets import UserRequestViewSet from portal.apps.user_requests.models import AerpawUserRequest from portal.apps.users.api.viewsets import UserViewSet @@ -93,18 +94,22 @@ def profile(request): api_request.user = request.user api_request.method = 'GET' u = UserViewSet(request=api_request) - # requests data - ur_api_request = Request(request=HttpRequest()) - ur_api_request.user = request.user - ur_api_request.method = 'GET' - ur_api_request.query_params.update( - {'user_id': user.id}) - ur = UserRequestViewSet(request=ur_api_request) - - user_requests = dict(ur.list(request=ur_api_request).data) user_credentials = u.credentials(request=request, pk=request.user.id).data user_data = u.retrieve(request=request, pk=request.user.id).data user_tokens = u.tokens(request=api_request, pk=request.user.id).data + + # modify query_params to get requests and messages data + request.query_params = QueryDict('', mutable=True) + request.query_params.update({'user_id': user.id, 'show_read': False, 'show_deleted': False}) + + # user requests data + ur = UserRequestViewSet(request=request) + user_requests = dict(ur.list(request=request).data) + + # user messages data + um = UserMessageViewSet(request=request) + user_messages = dict(um.list(request=request).data) + return render(request, 'profile.html', { @@ -112,7 +117,21 @@ def profile(request): 'user_data': user_data, 'user_tokens': user_tokens, 'user_credentials': user_credentials, + 'user_messages': user_messages, 'user_requests': user_requests, 'message': message, 'debug': DEBUG }) + + +def session_expired(request): + """ + :param request: + :return: + """ + print('session_expired') + return render(request, + 'login.html', + { + 'session_expired': True + }) diff --git a/portal/apps/resources/views.py b/portal/apps/resources/views.py index f42e6f0..c02fd67 100644 --- a/portal/apps/resources/views.py +++ b/portal/apps/resources/views.py @@ -59,11 +59,12 @@ def resource_list(request): max_range = int(current_page - 1) * int(REST_FRAMEWORK['PAGE_SIZE']) + int(REST_FRAMEWORK['PAGE_SIZE']) if max_range > count: max_range = count - + else: + resources = {} item_range = '{0} - {1}'.format(str(min_range), str(max_range)) except Exception as exc: message = exc - resources = None + resources = {} item_range = None next_page = None prev_page = None diff --git a/portal/apps/user_messages/__init__.py b/portal/apps/user_messages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/apps/user_messages/admin.py b/portal/apps/user_messages/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/portal/apps/user_messages/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/portal/apps/user_messages/api/serializers.py b/portal/apps/user_messages/api/serializers.py new file mode 100644 index 0000000..995670b --- /dev/null +++ b/portal/apps/user_messages/api/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers + +from portal.apps.user_messages.models import AerpawUserMessage + + +class UserMessageSerializerList(serializers.ModelSerializer): + """ + - created - string:sent_date + - created_by - string + - id - int:message_id + - is_deleted - bool + - is_read - bool + - message_body - string + - message_subject - string + - modified - string:last_modified_date + - modified_by - string + - read_date - string + - received_by (fk) - array of int:user_id + - sent_by (fk) - int:user_id + - uuid - string + """ + message_id = serializers.IntegerField(source='id', read_only=True) + sent_date = serializers.DateTimeField(source='created') + + class Meta: + model = AerpawUserMessage + fields = ['is_deleted', 'is_read', 'message_body', 'message_id', 'message_subject', 'sent_by', 'sent_date'] + + +class UserMessageSerializerDetail(serializers.ModelSerializer): + """ + - created - string:sent_date + - created_by - string + - id - int:message_id + - is_deleted - bool + - is_read - bool + - message_body - string + - message_subject - string + - modified - string:last_modified_date + - modified_by - string + - read_date - string + - received_by (fk) - array of int:user_id + - sent_by (fk) - int:user_id + - uuid - string + """ + last_modified_by = serializers.CharField(source='modified_by') + modified_date = serializers.DateTimeField(source='modified') + message_id = serializers.IntegerField(source='id', read_only=True) + sent_date = serializers.DateTimeField(source='created') + + class Meta: + model = AerpawUserMessage + fields = ['is_deleted', 'is_read', 'last_modified_by', 'message_body', 'message_id', 'message_subject', + 'modified_date', 'received_by', 'read_date', 'sent_by', 'sent_date'] diff --git a/portal/apps/user_messages/api/viewsets.py b/portal/apps/user_messages/api/viewsets.py new file mode 100644 index 0000000..18449d3 --- /dev/null +++ b/portal/apps/user_messages/api/viewsets.py @@ -0,0 +1,190 @@ +from datetime import datetime, timezone + +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from rest_framework import permissions +from rest_framework.exceptions import MethodNotAllowed, PermissionDenied +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT +from rest_framework.viewsets import GenericViewSet + +from portal.apps.resources.api.serializers import ResourceSerializerDetail +from portal.apps.user_messages.api.serializers import UserMessageSerializerDetail, UserMessageSerializerList +from portal.apps.user_messages.models import AerpawUserMessage +from portal.apps.users.models import AerpawUser + + +class UserMessageViewSet(GenericViewSet, RetrieveModelMixin, ListModelMixin, UpdateModelMixin): + """ + Resource + - paginated list + - retrieve one + - create + - update + - delete + """ + permission_classes = [permissions.IsAuthenticated] + queryset = AerpawUserMessage.objects.all().order_by('-created') + serializer_class = ResourceSerializerDetail + + def get_queryset(self): + show_deleted = self.request.query_params.get('show_deleted', None) + show_read = self.request.query_params.get('show_read', None) + user_id = self.request.query_params.get('user_id', None) + if str(show_deleted).casefold() == 'true': + show_deleted = Q(is_deleted__in=[True, False]) + else: + show_deleted = Q(is_deleted__in=[False]) + if str(show_read).casefold() == 'true': + show_read = Q(is_read__in=[True, False]) + else: + show_read = Q(is_read__in=[False]) + if user_id: + q_filter = (Q(message_owner__id=user_id)) & show_read & show_deleted + else: + q_filter = Q(message_owner__id=self.request.user.id) & show_read & show_deleted + + queryset = AerpawUserMessage.objects.filter( + q_filter + ).order_by('-created').distinct() + + return queryset + + def list(self, request, *args, **kwargs): + """ + GET: list user messages as paginated results + - is_deleted - bool + - is_read - bool + - message_body - string + - message_id - int + - message_subject - string + - sent_by - int:user_id + - sent_date - string + + @param show_deleted = user is_site_admin + @param show_read = optional - default False + @param user_id = user_id is user.id + + Permission: + - user is_active + """ + if request.user.is_active: + # validate user message + user_id = request.query_params.get('user_id', None) + if user_id: + # user_id must exist as valid user + user = get_object_or_404(AerpawUser, pk=user_id) + # user must be site admin or be the user themselves + if not request.user.is_site_admin() and user.id != request.user.id: + raise PermissionDenied( + detail="PermissionDenied: unable to GET /requests list?user_id=...") + # fetch response + page = self.paginate_queryset(self.get_queryset()) + if page: + serializer = UserMessageSerializerList(page, many=True) + else: + serializer = UserMessageSerializerList(self.get_queryset(), many=True) + response_data = [] + for u in serializer.data: + du = dict(u) + response_data.append( + { + 'is_deleted': du.get('is_deleted'), + 'is_read': du.get('is_read'), + 'message_body': du.get('message_body'), + 'message_id': du.get('message_id'), + 'message_subject': du.get('message_subject'), + 'sent_by': du.get('sent_by'), + 'sent_date': str(du.get('sent_date')) if du.get('sent_date') else None + } + ) + if page: + return self.get_paginated_response(response_data) + else: + return Response(response_data) + else: + raise PermissionDenied( + detail="PermissionDenied: unable to GET /messages list") + + def create(self, request): + """ + POST: create user message + """ + raise MethodNotAllowed(method="POST: /messages") + + def retrieve(self, request, *args, **kwargs): + """ + GET: user message as detailed result + - created - string:sent_date + - created_by - string + - id - int:message_id + - is_deleted - bool + - is_read - bool + - message_body - string + - message_owner - int:user_id + - message_subject - string + - modified - string:last_modified_date + - modified_by - string + - read_date - string + - received_by (fk) - array of int:user_id + - sent_by (fk) - int:user_id + - uuid - string + + Permission: + - user is_active + """ + user_message = get_object_or_404(self.queryset, pk=kwargs.get('pk')) + if request.user.is_active and user_message.message_owner == request.user: + serializer = UserMessageSerializerDetail(user_message) + du = dict(serializer.data) + response_data = { + 'is_deleted': du.get('is_deleted'), + 'is_read': du.get('is_read'), + 'last_modified_by': AerpawUser.objects.get(username=du.get('last_modified_by')).id, + 'message_body': du.get('message_body'), + 'message_id': du.get('message_id'), + 'message_subject': du.get('message_subject'), + 'modified_date': str(du.get('modified_date')) if du.get('modified_date') else None, + 'received_by': du.get('received_by'), + 'read_date': str(du.get('read_date')) if du.get('read_date') else None, + 'sent_by': du.get('sent_by'), + 'sent_date': str(du.get('sent_date')) if du.get('sent_date') else None + } + return Response(response_data) + else: + raise PermissionDenied( + detail="PermissionDenied: unable to GET /messages/{0} details".format(kwargs.get('pk'))) + + def update(self, request, *args, **kwargs): + """ + PUT: update existing message + """ + raise MethodNotAllowed(method="PUT,PATCH: /messages/{0}".format(kwargs.get('pk'))) + + def partial_update(self, request, *args, **kwargs): + """ + PATCH: update existing message + """ + return self.update(request, *args, **kwargs) + + def destroy(self, request, pk=None): + """ + DELETE: soft delete existing user message + - is_deleted - True + - is_read - True + + Permission: + - user is_owner of message + """ + user_message = get_object_or_404(self.queryset, pk=pk) + if user_message.message_owner == request.user: + user_message.is_deleted = True + user_message.is_read = True + user_message.modified = datetime.now(timezone.utc) + user_message.modified_by = request.user.username + user_message.save() + return Response(status=HTTP_204_NO_CONTENT) + else: + raise PermissionDenied( + detail="PermissionDenied: unable to DELETE /messages/{0} - user is not the owner".format(pk)) diff --git a/portal/apps/user_messages/apps.py b/portal/apps/user_messages/apps.py new file mode 100644 index 0000000..5c6458b --- /dev/null +++ b/portal/apps/user_messages/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserMessagesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'portal.apps.user_messages' diff --git a/portal/apps/user_messages/models.py b/portal/apps/user_messages/models.py new file mode 100644 index 0000000..2b3ce1a --- /dev/null +++ b/portal/apps/user_messages/models.py @@ -0,0 +1,51 @@ +from django.db import models + +from portal.apps.mixins.models import AuditModelMixin, BaseModel +from portal.apps.users.models import AerpawUser + + +class AerpawUserMessage(BaseModel, AuditModelMixin, models.Model): + """ + UserMessages + - created (from AuditModelMixin) + - created_by (from AuditModelMixin) + - id (from Basemodel) - request_id + - is_deleted - bool + - is_read - bool + - message_body - string + - message_owner - int:user_id + - message_subject - string + - modified (from AuditModelMixin) + - modified_by (from AuditModelMixin) + - read_date - string + - received_by (fk) - array of int:user_id + - sent_by (fk) - int:user_id + - uuid + """ + + is_deleted = models.BooleanField(default=False) + is_read = models.BooleanField(default=False) + message_body = models.TextField(blank=True, null=True) + message_owner = models.ForeignKey( + AerpawUser, + related_name='user_message_owner', + on_delete=models.PROTECT + ) + message_subject = models.TextField(blank=True, null=True) + read_date = models.DateTimeField(blank=True, null=True) + received_by = models.ManyToManyField( + AerpawUser, + related_name='user_message_received_by' + ) + sent_by = models.ForeignKey( + AerpawUser, + related_name='user_message_sent_by', + on_delete=models.PROTECT + ) + uuid = models.CharField(max_length=255, primary_key=False, editable=False) + + class Meta: + verbose_name = 'AERPAW User Message' + + def __str__(self): + return self.message_subject diff --git a/portal/apps/user_messages/tests.py b/portal/apps/user_messages/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/portal/apps/user_messages/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/portal/apps/user_messages/urls.py b/portal/apps/user_messages/urls.py new file mode 100644 index 0000000..dd57e1d --- /dev/null +++ b/portal/apps/user_messages/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from portal.apps.user_messages.views import user_message_detail, user_message_list + +urlpatterns = [ + path('', user_message_list, name='user_message_list'), + # path('create', project_create, name='project_create'), + path('', user_message_detail, name='user_message_detail'), + # path('/edit', project_edit, name='project_edit'), + # path('/members', project_members, name='project_members'), + # path('/owners', project_owners, name='project_owners'), +] diff --git a/portal/apps/user_messages/user_messages.py b/portal/apps/user_messages/user_messages.py new file mode 100644 index 0000000..8c12539 --- /dev/null +++ b/portal/apps/user_messages/user_messages.py @@ -0,0 +1,163 @@ +import os +from datetime import datetime, timezone +from uuid import uuid4 + +from django.core.mail import BadHeaderError, send_mail +from django.http import HttpResponse + +from portal.apps.user_messages.models import AerpawUserMessage +from portal.apps.users.models import AerpawUser + + +def send_portal_mail_from_message(request, *args, **kwargs): + """ + Derive mail parameters from UserMessages **kwargs + - message_body - string + - message_owner - int:user_id + - message_subject - string + - received_by - array of int:user_id + + send_mail(subject, message, from_email, recipient_list) + """ + try: + subject = kwargs.get('message_subject') + message = kwargs.get('message_body') + from_email = os.getenv('EMAIL_HOST_USER') + recipient_list = [u.email for u in AerpawUser.objects.filter(id__in=kwargs.get('received_by')).all()] + send_mail(subject, message, from_email, recipient_list) + except BadHeaderError: + return HttpResponse('Invalid header found.') + + +def user_message_create(request, *args, **kwargs): + """ + UserMessages **kwargs + - message_body - string + - message_owner - int:user_id + - message_subject - string + - received_by - array of int:user_id + """ + try: + user = AerpawUser.objects.filter(id=request.user.id).first() + received_by = AerpawUser.objects.filter(id__in=kwargs.get('received_by')).all() + # create user message + user_message = AerpawUserMessage() + user_message.created = datetime.now(timezone.utc) + user_message.created_by = user.username + user_message.message_body = kwargs.get('message_body', None) + user_message.message_owner = AerpawUser.objects.filter(id=kwargs.get('message_owner')).first() + user_message.message_subject = kwargs.get('message_subject', None) + user_message.modified = datetime.now(timezone.utc) + user_message.modified_by = user.username + user_message.sent_by = user + user_message.uuid = uuid4() + user_message.save() + # add received by to existing user message + for u in received_by: + user_message.received_by.add(u) + user_message.save() + return True + except Exception as exc: + print(exc) + return False + + +def generate_user_messages_from_user_request(request, user_request: dict): + """ + Example UserRequest - project join request + { + 'completed_by': None, + 'completed_date': None, + 'is_approved': None, + 'last_modified_by': 4, + 'modified_date': '2022-10-21T10:06:08.576584-04:00', + 'received_by': [1, 3], + 'request_id': 4, + 'request_note': '[example code project] - project join request', + 'request_type': 'project', + 'request_type_id': 3, + 'requested_by': 4, + 'requested_date': '2022-10-21T10:06:08.563034-04:00', + 'response_date': None, + 'response_note': None + } + + UserMessage - required components (per message) + - message_body - string + - message_owner - int:user_id + - message_subject - string + - received_by - array of int:user_id + """ + received_by = user_request.get('received_by') + message_owner_ids = received_by.copy() + message_owner_ids.append(user_request.get('requested_by')) + request_type = user_request.get('request_type') + if not user_request.get('response_date'): + message_subject = 'REQUEST: ' + user_request.get('request_note') + else: + message_subject = 'RESPONSE: ' + user_request.get('request_note') + received_by.append(user_request.get('requested_by')) + received_by.remove(request.user.id) + message_body = """ +request_type: {0} + +requested_by: {1} + +request_note: {2} +requested_date: {3} + +received_by: {4} + +completed_by: {5} +is_approved: {6} +response_note: {7} +response_date: {8} +""".format( + request_type, + AerpawUser.objects.filter(id=user_request.get('requested_by')).first().display_name, + user_request.get('request_note'), + datetime.strptime(user_request.get('requested_date'), "%Y-%m-%dT%H:%M:%S.%f%z").strftime("%m/%d/%Y, %H:%M:%S"), + [u.display_name for u in AerpawUser.objects.filter(id__in=received_by).all()], + AerpawUser.objects.filter(id=user_request.get('completed_by')).first().display_name if user_request.get( + 'completed_by') else '-----', + user_request.get('is_approved') if str(user_request.get('is_approved')).casefold() in ['true', + 'false'] else '-----', + user_request.get('response_note') if user_request.get('response_note') else '-----', + datetime.strptime(user_request.get('response_date'), "%Y-%m-%dT%H:%M:%S.%f%z").strftime( + "%m/%d/%Y, %H:%M:%S") if user_request.get('response_date') else '-----' + ) + for owner in message_owner_ids: + kwargs = { + 'message_body': message_body, + 'message_owner': owner, + 'message_subject': message_subject, + 'received_by': received_by + } + user_message_create(request=request, **kwargs) + + +def generate_new_user_welcome_message(request, user: AerpawUser): + message_body = """ +Hello {0}, + +Welcome to the AERPAW Portal + +User manuals, tutorials, and other relevant documentation can be found at the following links; +please refer to relevant instructions before attempting to use this Portal. + +- AERPAW Main Site: https://aerpaw.org/ +- AERPAW User Manual: https://sites.google.com/ncsu.edu/aerpaw-wiki/ +- AERPAW Acceptable Use Policy: https://sites.google.com/ncsu.edu/aerpaw-wiki/aerpaw-user-manual/2-experiment-web-portal/2-5-acceptable-use-policy-aup +""".format(user.display_name) + message_subject = '[AERPAW] Welcome {0} to the AERPAW portal!'.format(user.display_name) + kwargs = { + 'message_body': message_body, + 'message_owner': user.id, + 'message_subject': message_subject, + 'received_by': [user.id] + } + request.user = user + # create portal message + user_message_create(request=request, **kwargs) + # send welcome email + send_portal_mail_from_message(request=request, **kwargs) diff --git a/portal/apps/user_messages/views.py b/portal/apps/user_messages/views.py new file mode 100644 index 0000000..ab778b0 --- /dev/null +++ b/portal/apps/user_messages/views.py @@ -0,0 +1,114 @@ +from datetime import datetime, timezone +from urllib.parse import parse_qs, urlparse + +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, QueryDict +from django.shortcuts import get_object_or_404, render +from django.views.decorators.csrf import csrf_exempt +from rest_framework.request import Request + +from portal.apps.user_messages.api.viewsets import UserMessageViewSet +from portal.apps.user_messages.models import AerpawUserMessage +from portal.server.settings import DEBUG, REST_FRAMEWORK + + +@csrf_exempt +@login_required +def user_message_list(request): + message = None + try: + # check for query parameters + current_page = 1 + search_term = None + if request.method == 'POST': + if request.POST.get('delete_user_message'): + um_api_request = Request(request=HttpRequest()) + um_api_request.user = request.user + um_api_request.method = 'DELETE' + um = UserMessageViewSet(request=um_api_request) + um.destroy(request=um_api_request, pk=request.POST.get('delete_user_message')) + data_dict = {'user_id': request.user.id, 'show_read': True} + if request.GET.get('page'): + data_dict['page'] = request.GET.get('page') + current_page = int(request.GET.get('page')) + request.query_params = QueryDict('', mutable=True) + request.query_params.update(data_dict) + um = UserMessageViewSet(request=request) + user_messages = um.list(request=request) + # get prev, next and item range + next_page = None + prev_page = None + count = 0 + min_range = 0 + max_range = 0 + if user_messages.data: + user_messages = dict(user_messages.data) + prev_url = user_messages.get('previous', None) + if prev_url: + prev_dict = parse_qs(urlparse(prev_url).query) + try: + prev_page = prev_dict['page'][0] + except Exception as exc: + print(exc) + prev_page = 1 + next_url = user_messages.get('next', None) + if next_url: + next_dict = parse_qs(urlparse(next_url).query) + try: + next_page = next_dict['page'][0] + except Exception as exc: + print(exc) + next_page = 1 + count = int(user_messages.get('count')) + min_range = int(current_page - 1) * int(REST_FRAMEWORK['PAGE_SIZE']) + 1 + max_range = int(current_page - 1) * int(REST_FRAMEWORK['PAGE_SIZE']) + int(REST_FRAMEWORK['PAGE_SIZE']) + if max_range > count: + max_range = count + else: + user_messages = {} + item_range = '{0} - {1}'.format(str(min_range), str(max_range)) + except Exception as exc: + message = exc + user_messages = {} + item_range = None + next_page = None + prev_page = None + search_term = None + count = 0 + return render(request, + 'user_message_list.html', + { + 'user': request.user, + 'user_messages': user_messages, + 'item_range': item_range, + 'message': message, + 'next_page': next_page, + 'prev_page': prev_page, + 'search': search_term, + 'count': count, + 'debug': DEBUG + }) + + +@csrf_exempt +@login_required +def user_message_detail(request, user_message_id): + message = None + user_message_obj = get_object_or_404(AerpawUserMessage, pk=user_message_id) + try: + user_message_obj.is_read = True + user_message_obj.read_date = datetime.now(timezone.utc) + user_message_obj.save() + um = UserMessageViewSet(request=request) + user_message = um.retrieve(request=request, pk=user_message_id).data + except Exception as exc: + message = exc + user_message = None + return render(request, + 'user_message_detail.html', + { + 'user': request.user, + 'user_message': user_message, + 'message': message, + 'debug': DEBUG + }) diff --git a/portal/apps/user_requests/api/viewsets.py b/portal/apps/user_requests/api/viewsets.py index 1a26b4e..c7e482a 100644 --- a/portal/apps/user_requests/api/viewsets.py +++ b/portal/apps/user_requests/api/viewsets.py @@ -13,6 +13,7 @@ from portal.apps.experiments.models import AerpawExperiment from portal.apps.projects.models import AerpawProject from portal.apps.resources.api.serializers import ResourceSerializerDetail +from portal.apps.user_messages.user_messages import generate_user_messages_from_user_request from portal.apps.user_requests.api.serializers import UserRequestSerializerDetail, UserRequestSerializerList from portal.apps.user_requests.models import AerpawUserRequest from portal.apps.users.models import AerpawRolesEnum, AerpawUser @@ -257,6 +258,8 @@ def create(self, request): for r in received_by: user_request.received_by.add(r) user_request.save() + generate_user_messages_from_user_request(request=request, + user_request=self.retrieve(request, pk=user_request.id).data) return self.retrieve(request, pk=user_request.id) else: raise PermissionDenied( @@ -334,7 +337,7 @@ def update(self, request, *args, **kwargs): completed = False # check for is_approved if str(request.data.get('is_approved')).casefold() in ['true', 'false']: - is_approved = str(request.data.get('is_approved')).casefold() == 'true' + is_approved = True if str(request.data.get('is_approved')).casefold() == 'true' else False user_request.is_approved = is_approved completed = True if completed: @@ -344,6 +347,8 @@ def update(self, request, *args, **kwargs): user_request.response_date = datetime.now(timezone.utc) user_request.response_note = request.data.get('response_note') user_request.save() + generate_user_messages_from_user_request(request=request, + user_request=self.retrieve(request, pk=user_request.id).data) return self.retrieve(request, pk=user_request.id) else: raise PermissionDenied( diff --git a/portal/apps/user_requests/urls.py b/portal/apps/user_requests/urls.py index 874b657..8f820b7 100644 --- a/portal/apps/user_requests/urls.py +++ b/portal/apps/user_requests/urls.py @@ -4,9 +4,4 @@ urlpatterns = [ path('roles', user_role_reqeust_list, name='user_role_request_list'), - # path('create', project_create, name='project_create'), - # path('', project_detail, name='project_detail'), - # path('/edit', project_edit, name='project_edit'), - # path('/members', project_members, name='project_members'), - # path('/owners', project_owners, name='project_owners'), ] diff --git a/portal/apps/users/management/commands/create_aerpaw_admin_user.py b/portal/apps/users/management/commands/create_aerpaw_admin_user.py new file mode 100644 index 0000000..216d479 --- /dev/null +++ b/portal/apps/users/management/commands/create_aerpaw_admin_user.py @@ -0,0 +1,49 @@ +import os + +from django.core.management.base import BaseCommand, CommandError + +from portal.apps.users.oidc_users import MyOIDCAB + + +def create_aerpaw_admin_user(): + """ + Create and return a `User` with superuser (admin) permissions. + + Must provide a subset of the "claims" to mimic those normally received from CILogon for create_user + { + 'sub': 'http://cilogon.org/serverA/users/00000000', <-- mock value required by create_user + 'given_name': 'AERPAW', <-- modify from admin panel + 'family_name': 'Admin', <-- modify from admin panel + 'email': 'aerpaw@gmail.com', <-- EMAIL_HOST_USER environment variable + } + """ + try: + password = os.getenv('AERPAW_OPS_PORTAL_PASSWORD') + claims = { + 'sub': 'http://cilogon.org/serverA/users/00000000', + 'given_name': 'AERPAW', + 'family_name': 'Admin', + 'email': os.getenv('EMAIL_HOST_USER'), + } + create_superuser = MyOIDCAB() + user = create_superuser.create_user(claims) + user.set_password(password) + user.is_superuser = True + user.is_staff = True + user.save() + + return user + except Exception as e: + print(e) + + +class Command(BaseCommand): + help = 'Set node_display_name if NULL' + + def handle(self, *args, **kwargs): + try: + create_aerpaw_admin_user() + + except Exception as e: + print(e) + raise CommandError('Initialization failed.') diff --git a/portal/apps/users/oidc_users.py b/portal/apps/users/oidc_users.py index 8c279f2..3255dee 100644 --- a/portal/apps/users/oidc_users.py +++ b/portal/apps/users/oidc_users.py @@ -5,6 +5,7 @@ from mozilla_django_oidc.auth import OIDCAuthenticationBackend from portal.apps.profiles.models import AerpawUserProfile +from portal.apps.user_messages.user_messages import generate_new_user_welcome_message def generate_username(email): @@ -30,6 +31,10 @@ def create_user(self, claims): ) user.uuid = str(uuid4()) user.save() + try: + generate_new_user_welcome_message(request=self.request, user=user) + except Exception as exc: + print(exc) return user diff --git a/portal/server/settings.py b/portal/server/settings.py index b1994cf..e24996a 100644 --- a/portal/server/settings.py +++ b/portal/server/settings.py @@ -23,6 +23,10 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') +# Session expiration settings +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_COOKIE_AGE = 14400 + # SECURITY WARNING: don't run with debug turned on in production! if os.getenv('DJANGO_DEBUG').casefold() == 'true': DEBUG = True @@ -61,11 +65,13 @@ 'portal.apps.experiments', # experiments 'portal.apps.operations', # operations 'portal.apps.credentials', # credentials + 'portal.apps.user_messages', # user messages 'portal.apps.user_requests', # user requests ] # Add 'mozilla_django_oidc' authentication backend AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', # used for admin created by # 'mozilla_django_oidc.auth.OIDCAuthenticationBackend', 'portal.apps.users.oidc_users.MyOIDCAB', ) @@ -145,6 +151,7 @@ os.path.join(BASE_DIR, 'templates/projects'), os.path.join(BASE_DIR, 'templates/resources'), os.path.join(BASE_DIR, 'templates/rest_framework'), + os.path.join(BASE_DIR, 'templates/user_messages'), os.path.join(BASE_DIR, 'templates/user_requests'), ], 'APP_DIRS': True, @@ -249,6 +256,19 @@ OIDC_DRF_AUTH_BACKEND = 'mozilla_django_oidc.auth.OIDCAuthenticationBackend' +# AERPAW Email for development (use only 1 email backend at a time) +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# AERPAW Email for production (use only 1 email backend at a time) +# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +# EMAIL_HOST = os.getenv('EMAIL_HOST') +# EMAIL_PORT = os.getenv('EMAIL_PORT') +# EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') +# EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') +# EMAIL_ADMIN_USER = os.getenv('EMAIL_ADMIN_USER') +# EMAIL_USE_TLS = True +# EMAIL_USE_SSL = False + # Default Django logging is WARNINGS+ to console # so visible via docker-compose logs django LOGGING = { diff --git a/portal/server/urls.py b/portal/server/urls.py index a7f1cd4..b5c0fec 100644 --- a/portal/server/urls.py +++ b/portal/server/urls.py @@ -25,8 +25,10 @@ from portal.apps.experiments.api.viewsets import CanonicalExperimentResourceViewSet, ExperimentSessionViewSet, \ ExperimentViewSet, UserExperimentViewSet from portal.apps.operations.api.viewsets import CanonicalNumberViewSet +from portal.apps.profiles.views import session_expired from portal.apps.projects.api.viewsets import ProjectViewSet, UserProjectViewSet from portal.apps.resources.api.viewsets import ResourceViewSet +from portal.apps.user_messages.api.viewsets import UserMessageViewSet from portal.apps.user_requests.api.viewsets import UserRequestViewSet from portal.apps.users.api.viewsets import UserViewSet @@ -38,6 +40,7 @@ router.register(r'credentials', CredentialViewSet, basename='credentials') router.register(r'experiment-files', ExperimentFileViewSet, basename='experiment-files') router.register(r'experiments', ExperimentViewSet, basename='experiments') +router.register(r'messages', UserMessageViewSet, basename='messages') router.register(r'p-canonical-experiment-number', CanonicalNumberViewSet, basename='canonical-experiment-number') router.register(r'projects', ProjectViewSet, basename='projects') router.register(r'requests', UserRequestViewSet, basename='requests') @@ -51,6 +54,7 @@ # Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', TemplateView.as_view(template_name='home.html'), name='home'), + path('accounts/login/', session_expired, name='session_expired'), path('admin/', admin.site.urls), path('api/', include(router.urls)), path('api/auth/', include('rest_framework.urls', namespace='rest_framework')), @@ -62,6 +66,7 @@ path('credentials/', include('portal.apps.credentials.urls')), # credentials app path('operators/experiment-files/', include('portal.apps.experiment_files.urls')), # experiment_files app path('experiments/', include('portal.apps.experiments.urls')), # experiments app + path('messages/', include('portal.apps.user_messages.urls')), # user_messages app path('profile/', include('portal.apps.profiles.urls')), # profiles app path('projects/', include('portal.apps.projects.urls')), # projects app path('resources/', include('portal.apps.resources.urls')), # resources app diff --git a/portal/templates/portal/navbar.html b/portal/templates/portal/navbar.html index 2d5ecc5..fb4545a 100644 --- a/portal/templates/portal/navbar.html +++ b/portal/templates/portal/navbar.html @@ -74,7 +74,7 @@ {% else %} diff --git a/portal/templates/profiles/profile.html b/portal/templates/profiles/profile.html index bca4e12..fd2f814 100644 --- a/portal/templates/profiles/profile.html +++ b/portal/templates/profiles/profile.html @@ -282,8 +282,40 @@

Public Credentials {% endif %} +
+
+

Unread Messages + + {% if user_messages.count > 0 %} + ({{ user_messages.count }}) + {% else %} + (0) + {% endif %} + +

+ +
+ + + + + + - + {% for um in user_messages.results %} + + + + + + {% endfor %} + +
SubjectSent byDate
{{ um.message_subject|truncatechars:60 }}{{ um.sent_by|id_to_display_name }}{{ um.sent_date|str_to_datetime }}
+

Pending Requests @@ -349,6 +381,7 @@

Pending Requests
user:
{{ user_data|pprint }}
tokens:
{{ user_tokens|pprint }}
credentials:
{{ user_credentials|pprint }}
+
user_messages:
{{ user_messages|pprint }}
user_requests:
{{ user_requests|pprint }}

{% endif %} diff --git a/portal/templates/rest_framework/login.html b/portal/templates/rest_framework/login.html index 0ab438b..5e29193 100644 --- a/portal/templates/rest_framework/login.html +++ b/portal/templates/rest_framework/login.html @@ -26,7 +26,6 @@ {% include 'navbar.html' %}
- {% if user.is_authenticated %}

Current user: {{ user.email }}

@@ -34,26 +33,24 @@
{% else %} + {% if session_expired %} +

+ Current session has expired - Please Login again +

+ {% endif %}
AERPAW Portal uses CILogon to authenticate user identity:

- +

- NOTE: If this is your first time signing in an account will automatically be created for you based - on - the Identity - Provider you choose to authenticate with. - + NOTE: If this is your first time signing in an account will automatically be created for + you based on the Identity Provider you choose to authenticate with. {% endif %}
{% include 'footer.html' %} diff --git a/portal/templates/user_messages/user_message_detail.html b/portal/templates/user_messages/user_message_detail.html new file mode 100644 index 0000000..dcc60b6 --- /dev/null +++ b/portal/templates/user_messages/user_message_detail.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} +{% load static users_tags %} + +{% block title %} + Resources +{% endblock %} + +{% block content %} + {% if message %} +
{{ message }}
+ {% endif %} + {% if user.is_authenticated and user_message %} +
+
+

{{ user_message.message_subject }}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Subject:{{ user_message.message_subject }}
Body: +
{{ user_message.message_body }}
+
Sent By:{{ user_message.sent_by|id_to_display_name }}
Received By: + {% for r in user_message.received_by %} + {{ r|id_to_display_name }}
+ {% endfor %} +
Read Date{{ user_message.read_date|str_to_datetime }}
+ + + + + + + + + + + +
+ created date: {{ user_message.sent_date|str_to_datetime }} + + last modified date: {{ user_message.modified_date|str_to_datetime }} +
+ created by: {{ user_message.sent_by|id_to_username }} + + last modified by: {{ user_message.last_modified_by|id_to_username }} +
+
+ {% if debug %} +
+
user_message:
{{ user_message|pprint }}
+
+ {% endif %} + {% else %} +

You are not allowed to view this resource or are not logged in

+ + + + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/portal/templates/user_messages/user_message_list.html b/portal/templates/user_messages/user_message_list.html new file mode 100644 index 0000000..e1cdf5b --- /dev/null +++ b/portal/templates/user_messages/user_message_list.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% load static users_tags %} + +{% block title %} + Projects +{% endblock %} + +{% block content %} + {% if message %} +
{{ message }}
+ {% endif %} + {% if user.is_authenticated %} +
+
+

+ User Messages + + {% if user_messages.count %} + (messages: {{ user_messages.count }}) + {% else %} + (messages: 0) + {% endif %} + +

+ +
+ + + + + + + {% for um in user_messages.results %} + + + + + + + {% endfor %} + +
SubjectSent byDateOptions
+ {% if not um.is_read %} + + {% endif %} + {{ um.message_subject|truncatechars:60 }} + {{ um.sent_by|id_to_display_name }}{{ um.sent_date|str_to_datetime }} +
+ +
+ {% csrf_token %} + +
+
+
+
+ {% if prev_page %} + + {% else %} + + {% endif %} + Results: {{ item_range }} of {{ count }} + {% if next_page %} +
+ +
+ {% else %} + + {% endif %} +
+
+ {% if debug %} +
+
user_messages:
{{ user_messages|pprint }}
+
+ {% endif %} + {% else %} +

You are not logged in

+ + + + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/portal/templates/user_requests/user_role_request_list.html b/portal/templates/user_requests/user_role_request_list.html index c0dbbae..84519b2 100644 --- a/portal/templates/user_requests/user_role_request_list.html +++ b/portal/templates/user_requests/user_role_request_list.html @@ -22,48 +22,52 @@

{% endif %}

+

- - - - - - {% for ur in user_requests.results %} - - - - - - - {% endfor %} + + + + + {% for ur in user_requests.results %} + + + + + + + {% endfor %}
UserRequest NoteRequest DateResponse
- {{ ur.requested_by|id_to_display_name }}
- ({{ ur.requested_by|id_to_username }}) -
- {{ ur.request_note }} - - {{ ur.requested_date|str_to_datetime }} - -
-
- {% csrf_token %} - - - -
-
-
UserRequest NoteRequest DateResponse
+ {{ ur.requested_by|id_to_display_name }}
+ ({{ ur.requested_by|id_to_username }}) +
+ {{ ur.request_note }} + + {{ ur.requested_date|str_to_datetime }} + +
+
+ {% csrf_token %} + + + +
+
+
diff --git a/run_server.sh b/run_server.sh index c098d15..a6a3d71 100755 --- a/run_server.sh +++ b/run_server.sh @@ -54,6 +54,7 @@ if [[ "${MAKE_MIGRATIONS}" -eq 1 ]]; then "experiments" "operations" "credentials" + "user_messages" "user_requests" ) else