Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

153 changes: 152 additions & 1 deletion app/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
)

Expand Down
5 changes: 4 additions & 1 deletion app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<str:username>", views.UserProfileView.as_view()),
# feedback
path("send-request/", views.SendFeedbackView.as_view(), name="send-request"),

]
)
99 changes: 98 additions & 1 deletion app/views.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
)

5 changes: 5 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 4 additions & 1 deletion env/dev/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
INV_TOOLS_SSH_TOOLS_PW=FILL_IN
GITHUB_TOKEN=FILL_IN
GITHUB_REPO_OWNER=FILL_IN
GITHUB_REPO_NAME=FILL_IN
14 changes: 9 additions & 5 deletions env/dev/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion env/prod/.env
Original file line number Diff line number Diff line change
@@ -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
PELICAN_KEY_ID=FILL_IN
GITHUB_TOKEN=FILL_IN
GITHUB_REPO_OWNER=FILL_IN
GITHUB_REPO_NAME=FILL_IN
Loading
Loading