Skip to content

Commit

Permalink
Add OpenProjectIssueProvider and OpenProjectProviderMixin
Browse files Browse the repository at this point in the history
  • Loading branch information
jochenklar committed Nov 3, 2023
1 parent bad2984 commit 6c88bdc
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 51 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ dependencies = [
"iso8601~=2.0",
"markdown~=3.4",
"pypandoc~=1.11",
"requests-toolbelt~=1.0",
"rules~=3.3",
]

Expand Down
10 changes: 10 additions & 0 deletions rdmo/projects/models/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ def provider(self):
def get_absolute_url(self):
return reverse('project', kwargs={'pk': self.project.pk})

def get_option_value(self, key):
try:
return self.options.get(key=key).value
except IntegrationOption.DoesNotExist:
return None

def save_options(self, options):
for field in self.provider.fields:
try:
Expand Down Expand Up @@ -77,3 +83,7 @@ class Meta:

def __str__(self):
return f'{self.integration.project.title} / {self.integration.provider_key} / {self.key} = {self.value}'

@property
def title(self):
return self.key.title().replace('_', ' ')
257 changes: 221 additions & 36 deletions rdmo/projects/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
import json
from urllib.parse import quote

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _

from rdmo.core.plugins import Plugin
from rdmo.services.providers import GitHubProviderMixin, GitLabProviderMixin, OauthProviderMixin
from rdmo.services.providers import (
GitHubProviderMixin,
GitLabProviderMixin,
OauthProviderMixin,
OpenProjectProviderMixin,
)


class IssueProvider(Plugin):
Expand Down Expand Up @@ -37,17 +43,13 @@ def send_issue(self, request, issue, integration, subject, message, attachments)

return self.post(request, url, data)

def post_success(self, request, response):
def update_issue(self, request, remote_url):
from rdmo.projects.models import Integration, Issue, IssueResource

# get the upstream url of the issue
remote_url = self.get_issue_url(response)

# get the issue_id and integration_id from the session
issue_id = self.pop_from_session(request, 'issue_id')
integration_id = self.pop_from_session(request, 'integration_id')

# update the issue in rdmo
try:
issue = Issue.objects.get(pk=issue_id)
issue.status = Issue.ISSUE_STATUS_IN_PROGRESS
Expand All @@ -60,6 +62,13 @@ def post_success(self, request, response):
except ObjectDoesNotExist:
pass

def post_success(self, request, response):
# get the upstream url of the issue
remote_url = self.get_issue_url(response)

# update the issue in rdmo
self.update_issue(request, remote_url)

return HttpResponseRedirect(remote_url)

def get_post_url(self, request, issue, integration, subject, message, attachments):
Expand All @@ -78,20 +87,8 @@ class GitHubIssueProvider(GitHubProviderMixin, OauthIssueProvider):
description = _('This integration allow the creation of issues in arbitrary GitHub repositories. '
'The upload of attachments is not supported by GitHub.')

def get_repo(self, integration):
try:
return integration.options.get(key='repo').value
except ObjectDoesNotExist:
return None

def get_secret(self, integration):
try:
return integration.options.get(key='secret').value
except ObjectDoesNotExist:
return None

def get_post_url(self, request, issue, integration, subject, message, attachments):
repo = self.get_repo(integration)
repo = integration.get_option_value('repo')
if repo:
return f'https://api.github.com/repos/{repo}/issues'

Expand All @@ -105,7 +102,7 @@ def get_issue_url(self, response):
return response.json().get('html_url')

def webhook(self, request, integration):
secret = self.get_secret(integration)
secret = integration.get_option_value('secret')
header_signature = request.headers.get('X-Hub-Signature')

if (secret is not None) and (header_signature is not None):
Expand Down Expand Up @@ -147,7 +144,7 @@ def fields(self):
{
'key': 'secret',
'placeholder': 'Secret (random) string',
'help': _('The secret for a GitHub webhook to close a task.'),
'help': _('The secret for a GitHub webhook to close a task (optional).'),
'required': False,
'secret': True
}
Expand All @@ -163,20 +160,8 @@ def description(self):
return _(f'This integration allow the creation of issues in arbitrary repositories on {self.gitlab_url}. '
'The upload of attachments is not supported by GitLab.')

def get_repo(self, integration):
try:
return integration.options.get(key='repo').value
except ObjectDoesNotExist:
return None

def get_secret(self, integration):
try:
return integration.options.get(key='secret').value
except ObjectDoesNotExist:
return None

def get_post_url(self, request, issue, integration, subject, message, attachments):
repo = self.get_repo(integration)
repo = integration.get_option_value('repo')
if repo:
return '{}/api/v4/projects/{}/issues'.format(self.gitlab_url, quote(repo, safe=''))

Expand All @@ -190,7 +175,7 @@ def get_issue_url(self, response):
return response.json().get('web_url')

def webhook(self, request, integration):
secret = self.get_secret(integration)
secret = integration.get_option_value('secret')
header_token = request.headers.get('X-Gitlab-Token')

if (secret is not None) and (header_token is not None) and (header_token == secret):
Expand Down Expand Up @@ -229,7 +214,207 @@ def fields(self):
{
'key': 'secret',
'placeholder': 'Secret (random) string',
'help': _('The secret for a GitLab webhook to close a task.'),
'help': _('The secret for a GitLab webhook to close a task (optional).'),
'required': False,
'secret': True
}
]


class OpenProjectIssueProvider(OpenProjectProviderMixin, OauthIssueProvider):
add_label = _('Add OpenProject integration')
send_label = _('Send to OpenProject')

status_map = {
'New': 'open',
'To be scheduled': 'in_progress',
'Scheduled': 'in_progress',
'In progress': 'in_progress',
'Closed': 'closed',
'On hold': 'in_progress',
'Rejected': 'closed'
}

@property
def description(self):
return _(f'This integration allow the creation of issues on {self.openproject_url}.')

def send_issue(self, request, issue, integration, subject, message, attachments):
self.store_in_session(request, 'issue_id', issue.id)
self.store_in_session(request, 'integration_id', integration.id)
self.store_in_session(request, 'project_name', integration.get_option_value('project_name'))
self.store_in_session(request, 'work_package_type', integration.get_option_value('work_package_type'))
self.store_in_session(request, 'subject', subject)
self.store_in_session(request, 'message', message)
self.store_in_session(request, 'attachments', attachments)

return self.get_project_id(request)

def get_project_id(self, request):
project_name = self.pop_from_session(request, 'project_name')
query = quote(json.dumps([{
'name_and_identifier': {
'operator': '=',
'values': [project_name]
}
}]))
url = f'{self.api_url}/projects?filters={query}'

return self.get(request, url)

def get_type_id(self, request):
url = f'{self.api_url}/types'
return self.get(request, url)

def post_issue(self, request):
project_id = self.get_from_session(request, 'project_id')
type_id = self.get_from_session(request, 'type_id')
url = f'{self.api_url}/projects/{project_id}/work_packages'
data = {
'_links': {
'type': {
'href': f'/api/v3/types/{type_id}'
}
},
'subject': self.pop_from_session(request, 'subject'),
'description': {
'format': 'plain',
'raw': self.pop_from_session(request, 'message'),
}
}

return self.post(request, url, data)

def post_attachment(self, request):
work_package_id = self.get_from_session(request, 'work_package_id')
attachments = self.pop_from_session(request, 'attachments')

if attachments:
file_name, file_content, file_type = attachments[0]
url = f'{self.api_url}/work_packages/{work_package_id}/attachments'
multipart = {
'metadata': json.dumps({'fileName': file_name }),
'file': (file_name, file_content, file_type)
}

self.store_in_session(request, 'attachments', attachments[1:])
return self.post(request, url, multipart=multipart)

else:
# there are no attachments left, get the url of the work_package
remote_url = self.get_work_package_url(work_package_id)

# update the issue in rdmo
self.update_issue(request, remote_url)

# redirect to the work package in open project
return HttpResponseRedirect(remote_url)

def get_success(self, request, response):
if '/projects' in response.url:
try:
project_id = response.json()['_embedded']['elements'][0]['id']
self.store_in_session(request, 'project_id', project_id)
return self.get_type_id(request)

except (KeyError, IndexError):
return render(request, 'core/error.html', {
'title': _('Integration error'),
'errors': [_('OpenProject project could not be found.')]
}, status=200)

elif '/types' in response.url:
try:
work_package_type = self.pop_from_session(request, 'work_package_type')
for element in response.json()['_embedded']['elements']:
if element['name'] == work_package_type:
self.store_in_session(request, 'type_id', element['id'])
return self.post_issue(request)

except KeyError:
pass

return render(request, 'core/error.html', {
'title': _('Integration error'),
'errors': [_('OpenProject work package type could not be found.')]
}, status=200)

elif response.request.method == 'POST':
pass

# return an error if everything failed
return render(request, 'core/error.html', {
'title': _('Integration error'),
'errors': [_('The Integration is not configured correctly.')]
}, status=200)

def post_success(self, request, response):
if '/projects/' in response.url:
# get the upstream url of the issue
work_package_id = response.json()['id']
self.store_in_session(request, 'work_package_id', work_package_id)

# post the next attachment
return self.post_attachment(request)

def get_work_package_url(self, work_package_id):
return f'{self.openproject_url}/work_packages/{work_package_id}'

def webhook(self, request, integration):
secret = integration.get_option_value('secret')
header_signature = request.headers.get('X-Op-Signature')

if (secret is not None) and (header_signature is not None):
body_signature = 'sha1=' + hmac.new(secret.encode(), request.body, 'sha1').hexdigest()

if hmac.compare_digest(header_signature, body_signature):
try:
payload = json.loads(request.body.decode())
action = payload.get('action')
work_package = payload.get('work_package')

if action and work_package:
work_package_id = work_package.get('id')
work_package_url = self.get_work_package_url(work_package_id)
work_package_status = work_package.get('_links', {}).get('status', {}).get('title')

try:
issue_resource = integration.resources.get(url=work_package_url)
status_map = self.status_map
status_map.update(settings.OPENPROJECT_PROVIDER.get('status_map', {}))

if work_package_status in status_map:
print('-->' , status_map[work_package_status])
issue_resource.issue.status = status_map[work_package_status]
issue_resource.issue.save()

except ObjectDoesNotExist:
pass

return HttpResponse(status=200)

except json.decoder.JSONDecodeError as e:
return HttpResponse(e, status=400)

raise Http404

@property
def fields(self):
return [
{
'key': 'project_name',
'placeholder': 'Project',
'help': _('The name of the OpenProject url')
},
{
'key': 'work_package_type',
'placeholder': 'Work Package Type',
'help': _('The type of workpackage to create, e.g. "Task"')
},
{
'key': 'secret',
'placeholder': 'Secret (random) string',
'help': _('The secret for a OpenProject webhook to close a task (optional).'),
'required': False,
'secret': True
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ <h2>{% trans 'Send via integration' %}</h2>
<td>
{% for option in integration.options.all %}
{% if not option.secret %}
<p>{{ option.key.title }}: {{ option.value }}</p>
<p>{{ option.title }}: {{ option.value }}</p>
{% endif %}
{% endfor %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ <h2>{% trans 'Integrations' %}</h2>
<td>
{% for option in integration.options.all %}
{% if not option.secret %}
{{ option.key.title }}: {{ option.value }}<br />
{{ option.title }}: {{ option.value }}<br />
{% endif %}
{% endfor %}

Expand Down
Loading

0 comments on commit 6c88bdc

Please sign in to comment.