diff --git a/app/serializers.py b/app/serializers.py index ec32fc2..c8833bf 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -54,3 +54,11 @@ class Meta: model = Project fields = ["name", "members", "nodes"] + +class FeedbackSerializer(serializers.Serializer): + subject = serializers.CharField(max_length=255, required=True) + message = serializers.CharField(required=True) + email = serializers.EmailField(required=True) + request_type = serializers.ChoiceField(choices=['feedback', 'access request'], default='access request', required=False) + attachment = serializers.FileField(required=False, allow_null=True) + diff --git a/app/tests.py b/app/tests.py index 7176cd2..ab1aaf7 100644 --- a/app/tests.py +++ b/app/tests.py @@ -4,6 +4,7 @@ from rest_framework.authtoken.models import Token from rest_framework import status import uuid +from unittest.mock import patch, MagicMock from .models import Project, Node, UserMembership, NodeMembership from test_utils import assertDictContainsSubset @@ -1104,13 +1105,163 @@ def testLogoutCallbackAllowedHosts(self): ) + + +class TestSendFeedbackView(TestCase): + """ + TestSendFeedbackView tests that users can submit feedback as GitHub issues. + """ + + def testNeedsAuth(self): + r = self.client.post("/send-request/") + self.assertEqual(r.status_code, status.HTTP_401_UNAUTHORIZED) + + @override_settings( + GITHUB_TOKEN='test_token', + GITHUB_REPO_OWNER='waggle-sensor', + GITHUB_REPO_NAME='user-requests' + ) + @patch('app.views.requests.post') + def testSendFeedbackSuccess(self, mock_post): + user = create_random_user(email="testuser@example.com", name="Test User") + self.client.force_login(user) + + # Mock successful GitHub API response + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"html_url": "https://github.com/waggle-sensor/user-requests/issues/123"} + mock_post.return_value = mock_response + + data = { + "subject": "Test Feedback", + "message": "This is a test feedback message.", + "email": "submitter@example.com", + "request_type": "feedback", + } + + r = self.client.post("/send-request/", data, content_type="application/json") + self.assertEqual(r.status_code, status.HTTP_200_OK) + self.assertIn("message", r.json()) + self.assertIn("issue_url", r.json()) + + # Verify GitHub API was called + mock_post.assert_called_once() + call_args = mock_post.call_args + + # Verify the URL + self.assertIn("github.com", call_args[0][0]) + self.assertIn("waggle-sensor", call_args[0][0]) + self.assertIn("user-requests", call_args[0][0]) + + # Verify the payload + payload = call_args[1]['json'] + self.assertIn("[Feedback]", payload['title']) + self.assertIn("Test Feedback", payload['title']) + self.assertIn(user.username, payload['body']) + self.assertIn(user.email, payload['body']) + self.assertIn("This is a test feedback message.", payload['body']) + self.assertIn("feedback", payload['labels']) + + @override_settings( + GITHUB_TOKEN='test_token', + GITHUB_REPO_OWNER='waggle-sensor', + GITHUB_REPO_NAME='user-requests' + ) + @patch('app.views.requests.post') + def testSendFeedbackWithAttachment(self, mock_post): + user = create_random_user(email="testuser@example.com", name="Test User") + self.client.force_login(user) + + # Mock successful GitHub API responses for issue creation and comment + mock_issue_response = MagicMock() + mock_issue_response.status_code = 201 + mock_issue_response.json.return_value = { + "number": 124, + "html_url": "https://github.com/waggle-sensor/user-requests/issues/124" + } + + mock_comment_response = MagicMock() + mock_comment_response.status_code = 201 + + # First call returns issue response, second call returns comment response + mock_post.side_effect = [mock_issue_response, mock_comment_response] + + # Create a file attachment + from django.core.files.uploadedfile import SimpleUploadedFile + attachment = SimpleUploadedFile( + "test_file.txt", + b"This is test file content", + content_type="text/plain" + ) + + data = { + "subject": "Feedback with Attachment", + "message": "This feedback includes an attachment.", + "email": "submitter@example.com", + "request_type": "feedback", + "attachment": attachment, + } + + r = self.client.post("/send-request/", data, format='multipart') + if r.status_code != status.HTTP_200_OK: + print(f"Response status: {r.status_code}") + print(f"Response content: {r.content}") + self.assertEqual(r.status_code, status.HTTP_200_OK) + self.assertIn("issue_url", r.json()) + + # Verify two API calls were made (issue creation + comment creation) + self.assertEqual(mock_post.call_count, 2) + + # Verify first call (issue creation) + issue_call = mock_post.call_args_list[0] + self.assertIn("issues", issue_call[0][0]) + issue_payload = issue_call[1]['json'] + self.assertIn("[Feedback]", issue_payload['title']) + + # Verify second call (comment creation with attachment info) + comment_call = mock_post.call_args_list[1] + self.assertIn("comments", comment_call[0][0]) + self.assertIn("124", comment_call[0][0]) + comment_payload = comment_call[1]['json'] + self.assertIn("Attachment:", comment_payload['body']) + self.assertIn("test_file.txt", comment_payload['body']) + def testSendFeedbackMissingSubject(self): + user = create_random_user() + self.client.force_login(user) + + data = { + "message": "This feedback is missing a subject.", + "email": "submitter@example.com", + } + + r = self.client.post("/send-request/", data, content_type="application/json") + self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("subject", r.json()) + + def testSendFeedbackMissingMessage(self): + user = create_random_user() + self.client.force_login(user) + + data = { + "subject": "Test Subject", + "email": "submitter@example.com", + } + + r = self.client.post("/send-request/", data, content_type="application/json") + self.assertEqual(r.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("message", r.json()) + + + def create_random_user(**kwargs) -> User: from random import choice, randint from string import ascii_letters, printable + name = kwargs.pop("name", "".join(choice(printable) for _ in range(randint(4, 24)))) + return User.objects.create_user( username="".join(choice(ascii_letters) for _ in range(randint(43, 64))), - name="".join(choice(printable) for _ in range(randint(4, 24))), + name=name, **kwargs, ) diff --git a/app/urls.py b/app/urls.py index 31c1a14..bbd9891 100644 --- a/app/urls.py +++ b/app/urls.py @@ -57,7 +57,10 @@ views.UserAccessView.as_view(permission_classes=[AllowAny]), name="user-access-old", ), - # keeping compatbility with existing portal user profiles + # keeping compatibility with existing portal user profiles path("user_profile/", views.UserProfileView.as_view()), + # feedback + path("send-request/", views.SendFeedbackView.as_view(), name="send-request"), + ] ) diff --git a/app/views.py b/app/views.py index 7e3dd16..adf8315 100644 --- a/app/views.py +++ b/app/views.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.auth import login, get_user_model +import requests from django.http import ( HttpRequest, HttpResponse, @@ -16,10 +17,11 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView, RetrieveUpdateAPIView from rest_framework.permissions import IsAdminUser, IsAuthenticated, AllowAny from rest_framework.authtoken.models import Token +from rest_framework import status from django.contrib.auth import views as auth_views from django.contrib.auth.mixins import LoginRequiredMixin from django_slack import slack_message -from .serializers import UserSerializer, UserProfileSerializer, ProjectSerializer +from .serializers import UserSerializer, UserProfileSerializer, ProjectSerializer, FeedbackSerializer from .forms import UpdateSSHPublicKeysForm, CompleteLoginForm from .permissions import IsSelf, IsMatchingUsername from .models import Node, Project @@ -397,3 +399,98 @@ def set_site_cookie(response: HttpResponse, key: str, value: str): secure=settings.SESSION_COOKIE_SECURE, domain=settings.SAGE_COOKIE_DOMAIN, ) + + +class SendFeedbackView(APIView): + """ + Allows authenticated users to submit feedback as GitHub issues with optional attachments. + If an attachment is provided, its details (such as name and size) are included in a + comment on the GitHub issue, but the file content itself is not uploaded. + """ + permission_classes = [IsAuthenticated] + + def post(self, request: Request) -> Response: + serializer = FeedbackSerializer(data=request.data) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + user = request.user + subject = serializer.validated_data["subject"] + message = serializer.validated_data["message"] + email = serializer.validated_data["email"] + request_type = serializer.validated_data.get("request_type", "feedback") + attachment = serializer.validated_data.get("attachment") + + # Compose GitHub issue body + github_body = f"""**From:** {user.username} +**Name:** {user.name} +**Submitted Email:** {email} +**Account Email:** {user.email} + +{message} +""" + + # Create GitHub issue + github_token = settings.GITHUB_TOKEN + github_repo_owner = settings.GITHUB_REPO_OWNER + github_repo_name = settings.GITHUB_REPO_NAME + + if not github_token or not github_repo_owner or not github_repo_name: + return Response( + {"error": "GitHub integration not configured"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + url = f"https://api.github.com/repos/{github_repo_owner}/{github_repo_name}/issues" + headers = { + "Authorization": f"token {github_token}", + "Accept": "application/vnd.github.v3+json" + } + + payload = { + "title": f"[{request_type.title()}] {subject}", + "body": github_body, + "labels": [request_type] + } + + try: + response = requests.post(url, json=payload, headers=headers) + if response.status_code != 201: + return Response( + {"error": "Failed to submit feedback"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + issue_data = response.json() + issue_number = issue_data.get("number") + issue_url = issue_data.get("html_url") + + # Upload attachment as a comment if provided (not fully implemented yet) + if attachment: + comment_url = f"https://api.github.com/repos/{github_repo_owner}/{github_repo_name}/issues/{issue_number}/comments" + comment_body = f"**Attachment:** {attachment.name}\n\n[File uploaded: {attachment.name} ({attachment.size} bytes)]" + + comment_payload = {"body": comment_body} + comment_response = requests.post( + comment_url, + json=comment_payload, + headers=headers + ) + + if comment_response.status_code != 201: + return Response( + {"error": "Feedback submitted but attachment comment failed"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return Response( + {"message": "Feedback submitted successfully", "issue_url": issue_url}, + status=status.HTTP_200_OK + ) + except Exception as e: + return Response( + {"error": "Failed to submit feedback. Please try again later."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + diff --git a/config/settings/base.py b/config/settings/base.py index 364f3b2..e6a702b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -233,4 +233,9 @@ if PELICAN_ROOT_FOLDER and not PELICAN_ROOT_FOLDER.startswith("/"): raise ValueError("Setting PELICAN_ROOT_FOLDER must start with /") +# GitHub feedback configuration +GITHUB_TOKEN = env("GITHUB_TOKEN", str, "") +GITHUB_REPO_OWNER = env("GITHUB_REPO_OWNER", str, "") +GITHUB_REPO_NAME = env("GITHUB_REPO_NAME", str, "") + TIME_ZONE = "UTC" diff --git a/env/dev/.env b/env/dev/.env index 53f2f76..eedae7b 100644 --- a/env/dev/.env +++ b/env/dev/.env @@ -3,4 +3,7 @@ S3_SECRET_KEY=FILL_IN PELICAN_KEY_PATH=/app/env/issuer-key.pem #path to pem file in docker container PELICAN_KEY_ID=FILL_IN INV_TOOLS_TOKEN=FILL_IN -INV_TOOLS_SSH_TOOLS_PW=FILL_IN \ No newline at end of file +INV_TOOLS_SSH_TOOLS_PW=FILL_IN +GITHUB_TOKEN=FILL_IN +GITHUB_REPO_OWNER=FILL_IN +GITHUB_REPO_NAME=FILL_IN \ No newline at end of file diff --git a/env/dev/docker-compose.yaml b/env/dev/docker-compose.yaml index 9b90f82..2b53343 100644 --- a/env/dev/docker-compose.yaml +++ b/env/dev/docker-compose.yaml @@ -2,11 +2,11 @@ services: django: build: context: ../../ - dockerfile: ./env/dev/Dockerfile + dockerfile: ./env/dev/Dockerfile restart: always command: > - sh -c "python manage.py migrate && - python manage.py createsuperuser --noinput || true && + sh -c "python manage.py migrate && + python manage.py createsuperuser --noinput || true && python manage.py runserver 0.0.0.0:8000" ports: - "8000:8000" @@ -36,9 +36,13 @@ services: - "INV_TOOLS_REPO=/app/waggle-inventory-tools" #NOTE: uncomment these lines for automatic updates of the inventory tools repo # - "INV_TOOLS_REPO=https://github.com/waggle-sensor/waggle-inventory-tools.git" - # - "INV_TOOLS_TOKEN=${INV_TOOLS_TOKEN}" + # - "INV_TOOLS_TOKEN=${INV_TOOLS_TOKEN}" - "INV_TOOLS_SSH_TOOLS=/root/git" - "INV_TOOLS_SSH_CONFIG=/root/ssh" - - "INV_TOOLS_SSH_TOOLS_PW=${INV_TOOLS_SSH_TOOLS_PW}" + - "INV_TOOLS_SSH_TOOLS_PW=${INV_TOOLS_SSH_TOOLS_PW}" + # GitHub feedback settings + - "GITHUB_TOKEN=${GITHUB_TOKEN}" + - "GITHUB_REPO_OWNER=${GITHUB_REPO_OWNER}" + - "GITHUB_REPO_NAME=${GITHUB_REPO_NAME}" volumes: - ../../:/app:rw \ No newline at end of file diff --git a/env/prod/.env b/env/prod/.env index 8d4d496..fa3f8f6 100644 --- a/env/prod/.env +++ b/env/prod/.env @@ -1,4 +1,7 @@ S3_ACCESS_KEY=FILL_IN S3_SECRET_KEY=FILL_IN PELICAN_KEY_PATH=/app/env/issuer-key.pem #path to pem file in docker container -PELICAN_KEY_ID=FILL_IN \ No newline at end of file +PELICAN_KEY_ID=FILL_IN +GITHUB_TOKEN=FILL_IN +GITHUB_REPO_OWNER=FILL_IN +GITHUB_REPO_NAME=FILL_IN \ No newline at end of file diff --git a/env/prod/docker-compose.yaml b/env/prod/docker-compose.yaml index 72ec2e9..d7dec19 100644 --- a/env/prod/docker-compose.yaml +++ b/env/prod/docker-compose.yaml @@ -8,11 +8,11 @@ services: django: build: context: ../../ - dockerfile: ./Dockerfile + dockerfile: ./Dockerfile restart: always command: > - sh -c "env/wait-for-it.sh mysql:3306 -- python manage.py migrate && - python manage.py createsuperuser --noinput || true && + sh -c "env/wait-for-it.sh mysql:3306 -- python manage.py migrate && + python manage.py createsuperuser --noinput || true && gunicorn config.wsgi:application --bind 0.0.0.0:80 --reload --workers=3" ports: - 127.0.0.1:8000:80 @@ -51,6 +51,10 @@ services: - "PELICAN_LIFETIME=60" - "PELICAN_ROOT_URL=https://nrdstor.nationalresearchplatform.org:8443/sage" - "PELICAN_ROOT_FOLDER=/node-data" + # GitHub feedback settings + - "GITHUB_TOKEN=${GITHUB_TOKEN}" + - "GITHUB_REPO_OWNER=${GITHUB_REPO_OWNER}" + - "GITHUB_REPO_NAME=${GITHUB_REPO_NAME}" env_file: - .env volumes: