Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9341097
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
105 KB
Subscribers
None
View Options
diff --git a/swh/web/admin/add_forge_now.py b/swh/web/add_forge_now/admin_views.py
similarity index 75%
rename from swh/web/admin/add_forge_now.py
rename to swh/web/add_forge_now/admin_views.py
index a407bd99..ec12c216 100644
--- a/swh/web/admin/add_forge_now.py
+++ b/swh/web/add_forge_now/admin_views.py
@@ -1,39 +1,30 @@
# Copyright (C) 2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from django.conf import settings
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render
-from swh.web.admin.adminurls import admin_route
from swh.web.auth.utils import is_add_forge_now_moderator
-@admin_route(
- r"add-forge/requests/",
- view_name="add-forge-now-requests-moderation",
-)
@user_passes_test(is_add_forge_now_moderator, login_url=settings.LOGIN_URL)
def add_forge_now_requests_moderation_dashboard(request):
"""Moderation dashboard to allow listing current requests."""
return render(
request,
- "add_forge_now/requests-moderation.html",
+ "add-forge-requests-moderation.html",
{"heading": "Add forge now requests moderation"},
)
-@admin_route(
- r"add-forge/request/(?P<request_id>(\d)+)/",
- view_name="add-forge-now-request-dashboard",
-)
@user_passes_test(is_add_forge_now_moderator, login_url=settings.LOGIN_URL)
def add_forge_now_request_dashboard(request, request_id):
"""Moderation dashboard to allow listing current requests."""
return render(
request,
- "add_forge_now/request-dashboard.html",
+ "add-forge-request-dashboard.html",
{"request_id": request_id, "heading": "Add forge now request dashboard"},
)
diff --git a/swh/web/api/views/add_forge_now.py b/swh/web/add_forge_now/api_views.py
similarity index 100%
rename from swh/web/api/views/add_forge_now.py
rename to swh/web/add_forge_now/api_views.py
diff --git a/swh/web/templates/add_forge_now/common.html b/swh/web/add_forge_now/templates/add-forge-common.html
similarity index 98%
rename from swh/web/templates/add_forge_now/common.html
rename to swh/web/add_forge_now/templates/add-forge-common.html
index 24d34466..fed2359d 100644
--- a/swh/web/templates/add_forge_now/common.html
+++ b/swh/web/add_forge_now/templates/add-forge-common.html
@@ -1,72 +1,72 @@
-{% extends "../layout.html" %}
+{% extends "layout.html" %}
{% comment %}
Copyright (C) 2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% block header %}
{% render_bundle 'add_forge' %}
{% endblock %}
{% block title %}
Add forge now – Software Heritage archive
{% endblock %}
{% block navbar-content %}
<h4>Request the addition of a forge into the archive</h4>
{% endblock %}
{% block content %}
<div class="row mt-3">
<p>
“Add forge now” provides a service for Software Heritage users to save a
complete forge in the Software Heritage archive by requesting the addition
of the forge URL into the list of regularly visited forges.
</p>
{% if not user.is_authenticated %}
<p>
You can submit an “Add forge now” request only when you are authenticated,
please login to submit the request.
</p>
{% endif %}
</div>
<!-- Tabs in the page -->
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.view_name == 'forge-add-create' %}active{% endif %}"
href="{% url 'forge-add-create' %}" id="swh-add-forge-tab">
Submit a Request
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.view_name == 'forge-add-list' %}active{% endif %}"
href="{% url 'forge-add-list' %}" id="swh-add-forge-requests-list-tab">
Browse Requests
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.view_name == 'forge-add-help' %}active{% endif %}"
href="{% url 'forge-add-help' %}" id="swh-add-forge-requests-help-tab">
Help
</a>
</li>
</ul>
<div class="tab-content">
{% block tab_content %}
{% endblock %}
</div>
<script>
swh.webapp.initPage('add-forge-now');
swh.add_forge.onCreateRequestPageLoad();
</script>
{% endblock %}
diff --git a/swh/web/templates/add_forge_now/creation_form.html b/swh/web/add_forge_now/templates/add-forge-creation-form.html
similarity index 99%
rename from swh/web/templates/add_forge_now/creation_form.html
rename to swh/web/add_forge_now/templates/add-forge-creation-form.html
index e6542ac5..9f4b0355 100644
--- a/swh/web/templates/add_forge_now/creation_form.html
+++ b/swh/web/add_forge_now/templates/add-forge-creation-form.html
@@ -1,129 +1,129 @@
-{% extends "./common.html" %}
+{% extends "./add-forge-common.html" %}
{% comment %}
Copyright (C) 2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% block tab_content %}
<div id="swh-add-forge-submit-request" class="tab-pane active mt-3">
{% if not user.is_authenticated %}
<p class="text-primary">
You must be logged in to submit an add forge request. Please
<a id="loginLink" class="link-primary"
{% if oidc_enabled and 'remote_user' in request.GET %}
href="{% url 'oidc-login' %}?next={% url 'forge-add-create' %}"
{% else %}
href="{% url 'login' %}?next={% url 'forge-add-create' %}"
{% endif %}>log in</a>
</p>
{% else %}
<form method="POST" action="{% url 'api-1-add-forge-request-create' %}"
id="requestCreateForm" class="collapse show">
{% csrf_token %}
<div class="form-row">
<div class="form-group col-md-5">
<label for="swh-input-forge-type" class="swh-required-label">
Forge type
</label>
<select class="form-control" id="swh-input-forge-type"
name="forge_type" autofocus>
{% for forge_type in forge_types %}
<option value={{ forge_type }}>{{ forge_type}}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Supported forge types in software archive.
</small>
</div>
<div class="form-group col-md-7">
<label for="swh-input-forge-url" class="swh-required-label">
Forge URL
</label>
<input type="text" class="form-control" id="swh-input-forge-url"
name="forge_url" required>
<small class="form-text text-muted">
Remote URL of the forge.
</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-5">
<label for="swh-input-forge-contact-name" class="swh-required-label">
Forge contact name
</label>
<input type="text" class="form-control" name="forge_contact_name"
id="swh-input-forge-contact-name" required>
<small class="form-text text-muted">
Name of the forge administrator.
</small>
</div>
<div class="form-group col-md-7">
<label for="swh-input-forge-contact-email" class="swh-required-label">
Forge contact email
</label>
<input type="email" class="form-control" name="forge_contact_email"
id="swh-input-forge-contact-email" required>
<small class="form-text text-muted">
Email of the forge administrator. The given email address will not be used
for any purpose outside the “add forge now” process.
</small>
</div>
</div>
<div class="form-row">
<div class="form-group form-check">
<input class="form-check-input" type="checkbox"
id="swh-input-consent-check" name="submitter_forward_username">
<label for="swh-input-consent-check">
I consent to add my username in the communication with the forge.
</label>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="swh-input-forge-comment">Comment</label>
<textarea class="form-control" id="swh-input-forge-comment"
name="forge_contact_comment" rows="3">
</textarea>
<small class="form-text text-muted">
Optionally, leave a comment to the moderator regarding your request.
</small>
</div>
</div>
<div class="form-row">
<div class="col-md-12">
<input id="swh-input-form-submit" type="submit" value="Submit Add Request"
class="btn btn-default float-right">
</div>
</div>
<div class="form-row">
<div class="col-md-12">
<h3 class="text-center">
<span id="userMessage" class="badge"></span>
</h3>
<p class="text-center">
<span id="userMessageDetail"></span>
</p>
</div>
</div>
</form>
<p>
Once an add-forge-request is submitted, its status can be viewed in
the <a id="swh-show-forge-add-requests-list" href="#browse-requests">
submitted requests list</a>. This process involves a moderator approval and
might take a few days to handle (it primarily depends on the response
time from the forge).
</p>
{% endif %}
</div>
{% endblock %}
diff --git a/swh/web/templates/add_forge_now/help.html b/swh/web/add_forge_now/templates/add-forge-help.html
similarity index 98%
rename from swh/web/templates/add_forge_now/help.html
rename to swh/web/add_forge_now/templates/add-forge-help.html
index 650368f2..5174349a 100644
--- a/swh/web/templates/add_forge_now/help.html
+++ b/swh/web/add_forge_now/templates/add-forge-help.html
@@ -1,89 +1,89 @@
-{% extends "./common.html" %}
+{% extends "./add-forge-common.html" %}
{% comment %}
Copyright (C) 2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% block tab_content %}
<div id="swh-add-forge-requests-help" class="tab-pane active mt-3">
<p>
For submitting an "Add forge now" request", you have to provide the following details:
</p>
<ul>
<li><strong>Forge type:</strong>Type of the forge you would like to add.
Supported forge types in software heritage currently are:
<ul>
<li><code>cgit</code>, for <a href="https://git.zx2c4.com/cgit/">cgit</a> forges</li>
<li><code>gitea</code>, for <a href="https://gitea.io/en-us/">gitea</a> forges</li>
<li><code>gitlab</code>, for <a href="https://about.gitlab.com/install/">gitlab</a> forges</li>
<li><code>heptapod</code>, for <a href="https://heptapod.net/">heptapod</a> forges</li>
...
</ul>
</li>
<li><strong>Forge url:</strong>The URL of the selected forge. This must be unique.
</li>
<li><strong>Forge contact name:</strong>Contact name of the forge administrator
</li>
<li><strong>Forge contact email:</strong>Contact email of the forge administrator. An email
will be sent to the given address to notify about the request.
</li>
<li><strong>Consent checkbox:</strong> This checkbox's purpose is to know whether we can
explicitly mention the user's login within the email sent to the forge. If
not checked, the user's name won't be mentioned in the email at all.
</li>
<li><strong>Comment:</strong> (Optionally) For the user to mention something more about
their request to the add-forge-now moderator.
</li>
</ul>
<p class="mt-1">
Once submitted, your "add forge" request can be in one
of the following states
</p>
<ul>
<li>
<strong>Pending:</strong>
The request was submitted and is waiting for a moderator
to validate
</li>
<li>
<strong>Waiting for feedback:</strong>
The request was processed by a moderator and the forge was contacted.
</li>
<li>
<strong>Feedback to handle:</strong>
The forge has responded to the request and
there is feedback to handle for the request.
</li>
<li>
<strong>Accepted:</strong>
The request has been accepted.
</li>
<li>
<strong>Scheduled:</strong>
The requested forge listing has been scheduled.
</li>
<li>
<strong>First listing done:</strong>
The first listing of the requested forge has been completed
</li>
<li>
<strong>First origin loaded:</strong>
The first repositories (or origins) from the requested forge have been loaded and archived.
</li>
<li><strong>Rejected:</strong>The request is invalid. It is rejected by a moderator with an explanation.</li>
<li><strong>Denied:</strong>The forge administrator(s) denied the request to list their forge.</li>
<li><strong>Suspended:</strong>The forge listing is not supported yet.</li>
</ul>
</div>
{% endblock %}
diff --git a/swh/web/templates/add_forge_now/list.html b/swh/web/add_forge_now/templates/add-forge-list.html
similarity index 94%
rename from swh/web/templates/add_forge_now/list.html
rename to swh/web/add_forge_now/templates/add-forge-list.html
index eb33fa69..1987168f 100644
--- a/swh/web/templates/add_forge_now/list.html
+++ b/swh/web/add_forge_now/templates/add-forge-list.html
@@ -1,24 +1,24 @@
-{% extends "./common.html" %}
+{% extends "./add-forge-common.html" %}
{% comment %}
Copyright (C) 2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% block tab_content %}
<div id="swh-add-forge-requests-list" class="tab-pane active mt-3" style="width: 100%;">
<table id="add-forge-request-browse" class="table swh-table swh-table-striped">
<thead>
<tr>
<th>Submission date</th>
<th>Forge type</th>
<th>Forge URL</th>
<th>Status</th>
</tr>
</thead>
</table>
<div id="add-forge-browse-request-error"></div>
</div>
{% endblock %}
diff --git a/swh/web/templates/add_forge_now/request-dashboard.html b/swh/web/add_forge_now/templates/add-forge-request-dashboard.html
similarity index 99%
rename from swh/web/templates/add_forge_now/request-dashboard.html
rename to swh/web/add_forge_now/templates/add-forge-request-dashboard.html
index 0f9e14c9..53eb08dd 100644
--- a/swh/web/templates/add_forge_now/request-dashboard.html
+++ b/swh/web/add_forge_now/templates/add-forge-request-dashboard.html
@@ -1,121 +1,121 @@
-{% extends "../layout.html" %}
+{% extends "layout.html" %}
{% comment %}
Copyright (C) 2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% block header %}
{% render_bundle 'add_forge' %}
{% endblock %}
{% block title %}{{heading}} – Software Heritage archive{% endblock %}
{% block navbar-content %}
<h4>Add forge now request dashboard</h4>
{% endblock %}
{% block content %}
<div class="container">
<div id="fetchError" class="row d-none">
<h3>Error fetching information about the request</h3>
</div>
<div class="row" id="requestDetails">
<div id="ForgeRequestActions" class="request-actions">
<div>
<div class="accordion" id="requestHistory">
</div>
</div>
<div>
<form method="POST"
action="{% url 'api-1-add-forge-request-update' request_id %}"
style="padding-top: 5px;" id="updateRequestForm">
{% csrf_token %}
<div class="form-row">
<div class="form-group col-md-6">
<label for="decisionOptions" class="swh-required-label">Choose your decision</label>
<select class="form-control" id="decisionOptions" name="new_status">
</select>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<label for="swh-input-forge-comment" class="swh-required-label">Comment</label>
<textarea class="form-control" id="updateComment" name="text" rows="3" required></textarea>
<small class="form-text text-muted">
Enter a comment related to your decision.
</small>
</div>
</div>
<div class="form-group col-md-6">
<button type="submit" class="btn btn-default mb-2">Submit</button>
</div>
<div class="form-row">
<div class="col-md-12">
<h3 class="text-center">
<span id="userMessage" class="badge"></span>
</h3>
</div>
</div>
</form>
</div>
</div>
<div id="ForgeRequestDetails" class="col-md-2 details-pane">
<div>
<strong>Request status</strong>
</div>
<small class="details-text" id="requestStatus"></small>
<hr />
<div>
<strong>Forge type</strong>
</div>
<small class="details-text" id="requestType"></small>
<hr />
<div>
<strong>Forge URL</strong>
</div>
<small class="details-text" id="requestURL"></small>
<hr />
<div>
<strong>Contact name</strong>
</div>
<small class="details-text" id="requestContactName"></small>
<hr />
<div>
<strong>Consent to use name</strong>
</div>
<small class="details-text" id="requestContactConsent"></small>
<hr />
<div>
<strong>Contact email</strong>
</div>
<small class="details-text" id="requestContactEmail"></small>
<hr />
<div>
<strong>Message</strong>
</div>
<p><small id="submitterMessage" class="details-text"></small></p>
<hr />
<div>
<button class="btn btn-link" id="contactForgeAdmin"
emailSubject="" emailTo="" emailCc="">
Send message to the forge
</button>
</div>
</div>
</div>
</div>
<script>
swh.webapp.initPage('add-forge-now-moderation');
swh.add_forge.onRequestDashboardLoad("{{ request_id }}");
</script>
{% endblock %}
diff --git a/swh/web/templates/add_forge_now/requests-moderation.html b/swh/web/add_forge_now/templates/add-forge-requests-moderation.html
similarity index 97%
rename from swh/web/templates/add_forge_now/requests-moderation.html
rename to swh/web/add_forge_now/templates/add-forge-requests-moderation.html
index 6d3010d1..dcdbe7b1 100644
--- a/swh/web/templates/add_forge_now/requests-moderation.html
+++ b/swh/web/add_forge_now/templates/add-forge-requests-moderation.html
@@ -1,48 +1,48 @@
-{% extends "../layout.html" %}
+{% extends "layout.html" %}
{% comment %}
Copyright (C) 2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% block header %}
{% render_bundle 'add_forge' %}
{% endblock %}
{% block title %}{{ heading }} – Software Heritage archive{% endblock %}
{% block navbar-content %}
<h4>Add forge now moderation</h4>
{% endblock %}
{% block content %}
<div class="row">
<div id="swh-add-forge-requests-list" style="width: 100%;">
<table id="swh-add-forge-now-moderation-list" class="table swh-table swh-table-striped">
<thead>
<tr>
<th>ID</th>
<th>Submission date</th>
<th>Forge type</th>
<th>Forge URL</th>
<th>Moderator Name</th>
<th>Last Modified Date</th>
<th>Status</th>
</tr>
</thead>
</table>
<p id="swh-add-forge-now-moderation-list-error"></p>
</div>
</div>
<script>
swh.webapp.initPage('add-forge-now-moderation');
swh.add_forge.onModerationPageLoad();
</script>
{% endblock %}
diff --git a/swh/web/add_forge_now/urls.py b/swh/web/add_forge_now/urls.py
new file mode 100644
index 00000000..e8b1423e
--- /dev/null
+++ b/swh/web/add_forge_now/urls.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2022 The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU Affero General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from django.urls import re_path as url
+
+from swh.web.add_forge_now.admin_views import (
+ add_forge_now_request_dashboard,
+ add_forge_now_requests_moderation_dashboard,
+)
+
+# register Web API endpoints
+import swh.web.add_forge_now.api_views # noqa
+from swh.web.add_forge_now.views import (
+ add_forge_request_list_datatables,
+ create_request_create,
+ create_request_help,
+ create_request_list,
+ create_request_message_source,
+)
+
+urlpatterns = [
+ url(
+ r"^add-forge/request/list/datatables/$",
+ add_forge_request_list_datatables,
+ name="add-forge-request-list-datatables",
+ ),
+ url(r"^add-forge/request/create/$", create_request_create, name="forge-add-create"),
+ url(r"^add-forge/request/list/$", create_request_list, name="forge-add-list"),
+ url(
+ r"^add-forge/request/message-source/(?P<id>\d+)/$",
+ create_request_message_source,
+ name="forge-add-message-source",
+ ),
+ url(r"^add-forge/request/help/$", create_request_help, name="forge-add-help"),
+ url(
+ r"^admin/add-forge/requests/$",
+ add_forge_now_requests_moderation_dashboard,
+ name="add-forge-now-requests-moderation",
+ ),
+ url(
+ r"^admin/add-forge/request/(?P<request_id>(\d)+)/$",
+ add_forge_now_request_dashboard,
+ name="add-forge-now-request-dashboard",
+ ),
+]
diff --git a/swh/web/add_forge_now/views.py b/swh/web/add_forge_now/views.py
index c17fdb06..a2f41c10 100644
--- a/swh/web/add_forge_now/views.py
+++ b/swh/web/add_forge_now/views.py
@@ -1,160 +1,142 @@
# Copyright (C) 2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from typing import Any, Dict, List
from django.conf import settings
from django.contrib.auth.decorators import user_passes_test
from django.core.paginator import Paginator
from django.db.models import Q
from django.http.request import HttpRequest
from django.http.response import HttpResponse, JsonResponse
from django.shortcuts import render
-from django.urls import re_path as url
-from swh.web.add_forge_now.models import Request as AddForgeRequest
-from swh.web.add_forge_now.models import RequestHistory
-from swh.web.api.views.add_forge_now import (
+from swh.web.add_forge_now.api_views import (
AddForgeNowRequestPublicSerializer,
AddForgeNowRequestSerializer,
)
+from swh.web.add_forge_now.models import Request as AddForgeRequest
+from swh.web.add_forge_now.models import RequestHistory
from swh.web.auth.utils import is_add_forge_now_moderator
def add_forge_request_list_datatables(request: HttpRequest) -> HttpResponse:
"""Dedicated endpoint used by datatables to display the add-forge
requests in the Web UI.
"""
draw = int(request.GET.get("draw", 0))
add_forge_requests = AddForgeRequest.objects.all()
table_data: Dict[str, Any] = {
"recordsTotal": add_forge_requests.count(),
"draw": draw,
}
search_value = request.GET.get("search[value]")
column_order = request.GET.get("order[0][column]")
field_order = request.GET.get(f"columns[{column_order}][name]", "id")
order_dir = request.GET.get("order[0][dir]", "desc")
if field_order:
if order_dir == "desc":
field_order = "-" + field_order
add_forge_requests = add_forge_requests.order_by(field_order)
per_page = int(request.GET.get("length", 10))
page_num = int(request.GET.get("start", 0)) // per_page + 1
if search_value:
add_forge_requests = add_forge_requests.filter(
Q(forge_type__icontains=search_value)
| Q(forge_url__icontains=search_value)
| Q(status__icontains=search_value)
)
if (
int(request.GET.get("user_requests_only", "0"))
and request.user.is_authenticated
):
add_forge_requests = add_forge_requests.filter(
submitter_name=request.user.username
)
paginator = Paginator(add_forge_requests, per_page)
page = paginator.page(page_num)
if is_add_forge_now_moderator(request.user):
requests = AddForgeNowRequestSerializer(page.object_list, many=True).data
else:
requests = AddForgeNowRequestPublicSerializer(page.object_list, many=True).data
results = [dict(req) for req in requests]
table_data["recordsFiltered"] = add_forge_requests.count()
table_data["data"] = results
return JsonResponse(table_data)
FORGE_TYPES: List[str] = [
"bitbucket",
"cgit",
"gitlab",
"gitea",
"heptapod",
]
def create_request_create(request):
"""View to create a new 'add_forge_now' request."""
return render(
request,
- "add_forge_now/creation_form.html",
+ "add-forge-creation-form.html",
{"forge_types": FORGE_TYPES},
)
def create_request_list(request):
"""View to list existing 'add_forge_now' requests."""
return render(
request,
- "add_forge_now/list.html",
+ "add-forge-list.html",
)
def create_request_help(request):
"""View to explain 'add_forge_now'."""
return render(
request,
- "add_forge_now/help.html",
+ "add-forge-help.html",
)
@user_passes_test(
is_add_forge_now_moderator,
redirect_field_name="next_path",
login_url=settings.LOGIN_URL,
)
def create_request_message_source(request: HttpRequest, id: int) -> HttpResponse:
"""View to retrieve the message source for a given request history entry"""
try:
history_entry = RequestHistory.objects.select_related("request").get(
pk=id, message_source__isnull=False
)
assert history_entry.message_source is not None
except RequestHistory.DoesNotExist:
return HttpResponse(status=404)
response = HttpResponse(
bytes(history_entry.message_source), content_type="text/email"
)
filename = f"add-forge-now-{history_entry.request.forge_domain}-message{id}.eml"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
-
-
-urlpatterns = [
- url(
- r"^add-forge/request/list/datatables/$",
- add_forge_request_list_datatables,
- name="add-forge-request-list-datatables",
- ),
- url(r"^add-forge/request/create/$", create_request_create, name="forge-add-create"),
- url(r"^add-forge/request/list/$", create_request_list, name="forge-add-list"),
- url(
- r"^add-forge/request/message-source/(?P<id>\d+)/$",
- create_request_message_source,
- name="forge-add-message-source",
- ),
- url(r"^add-forge/request/help/$", create_request_help, name="forge-add-help"),
-]
diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py
index 4ef703a1..43f9471f 100644
--- a/swh/web/admin/urls.py
+++ b/swh/web/admin/urls.py
@@ -1,27 +1,23 @@
# Copyright (C) 2018-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from django.contrib.auth.views import LoginView
from django.shortcuts import redirect
from django.urls import re_path as url
from swh.web.admin.adminurls import AdminUrls
import swh.web.admin.deposit # noqa
-from swh.web.config import is_feature_enabled
-
-if is_feature_enabled("add_forge_now"):
- import swh.web.admin.add_forge_now # noqa
def _admin_default_view(request):
return redirect("admin-origin-save-requests")
urlpatterns = [
url(r"^$", _admin_default_view, name="admin"),
url(r"^login/$", LoginView.as_view(template_name="login.html"), name="login"),
]
urlpatterns += AdminUrls.get_url_patterns()
diff --git a/swh/web/api/urls.py b/swh/web/api/urls.py
index 04297017..9fe9f56f 100644
--- a/swh/web/api/urls.py
+++ b/swh/web/api/urls.py
@@ -1,23 +1,22 @@
# Copyright (C) 2017-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from swh.web.api.apiurls import APIUrls
-import swh.web.api.views.add_forge_now # noqa
import swh.web.api.views.content # noqa
import swh.web.api.views.directory # noqa
import swh.web.api.views.graph # noqa
import swh.web.api.views.identifiers # noqa
import swh.web.api.views.metadata # noqa
import swh.web.api.views.origin # noqa
import swh.web.api.views.ping # noqa
import swh.web.api.views.raw # noqa
import swh.web.api.views.release # noqa
import swh.web.api.views.revision # noqa
import swh.web.api.views.snapshot # noqa
import swh.web.api.views.stat # noqa
import swh.web.api.views.vault # noqa
urlpatterns = APIUrls.get_url_patterns()
diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py
index 0e40bec1..828900f7 100644
--- a/swh/web/common/utils.py
+++ b/swh/web/common/utils.py
@@ -1,524 +1,523 @@
# Copyright (C) 2017-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from datetime import datetime, timezone
import functools
import os
import re
from typing import Any, Callable, Dict, List, Mapping, Optional
import urllib.parse
from bs4 import BeautifulSoup
from docutils.core import publish_parts
import docutils.parsers.rst
import docutils.utils
from docutils.writers.html5_polyglot import HTMLTranslator, Writer
from iso8601 import ParseError, parse_date
from pkg_resources import get_distribution
from prometheus_client.registry import CollectorRegistry
import requests
from requests.auth import HTTPBasicAuth
from django.conf import settings
from django.core.cache import cache
from django.core.cache.backends.base import DEFAULT_TIMEOUT
from django.http import HttpRequest, QueryDict
from django.shortcuts import redirect
from django.urls import resolve
from django.urls import reverse as django_reverse
from swh.web.auth.utils import (
ADD_FORGE_MODERATOR_PERMISSION,
ADMIN_LIST_DEPOSIT_PERMISSION,
MAILMAP_ADMIN_PERMISSION,
)
from swh.web.common.exc import BadInputExc, sentry_capture_exception
from swh.web.config import SWH_WEB_SERVER_NAME, get_config, search
SWH_WEB_METRICS_REGISTRY = CollectorRegistry(auto_describe=True)
SWHID_RE = "swh:1:[a-z]{3}:[0-9a-z]{40}"
swh_object_icons = {
"alias": "mdi mdi-star",
"branch": "mdi mdi-source-branch",
"branches": "mdi mdi-source-branch",
"content": "mdi mdi-file-document",
"cnt": "mdi mdi-file-document",
"directory": "mdi mdi-folder",
"dir": "mdi mdi-folder",
"origin": "mdi mdi-source-repository",
"ori": "mdi mdi-source-repository",
"person": "mdi mdi-account",
"revisions history": "mdi mdi-history",
"release": "mdi mdi-tag",
"rel": "mdi mdi-tag",
"releases": "mdi mdi-tag",
"revision": "mdi mdi-rotate-90 mdi-source-commit",
"rev": "mdi mdi-rotate-90 mdi-source-commit",
"snapshot": "mdi mdi-camera",
"snp": "mdi mdi-camera",
"visits": "mdi mdi-calendar-month",
}
def reverse(
viewname: str,
url_args: Optional[Dict[str, Any]] = None,
query_params: Optional[Mapping[str, Optional[str]]] = None,
current_app: Optional[str] = None,
urlconf: Optional[str] = None,
request: Optional[HttpRequest] = None,
) -> str:
"""An override of django reverse function supporting query parameters.
Args:
viewname: the name of the django view from which to compute a url
url_args: dictionary of url arguments indexed by their names
query_params: dictionary of query parameters to append to the
reversed url
current_app: the name of the django app tighten to the view
urlconf: url configuration module
request: build an absolute URI if provided
Returns:
str: the url of the requested view with processed arguments and
query parameters
"""
if url_args:
url_args = {k: v for k, v in url_args.items() if v is not None}
url = django_reverse(
viewname, urlconf=urlconf, kwargs=url_args, current_app=current_app
)
params: Dict[str, str] = {}
if query_params:
params = {k: v for k, v in query_params.items() if v is not None}
if params:
query_dict = QueryDict("", mutable=True)
query_dict.update(dict(sorted(params.items())))
url += "?" + query_dict.urlencode(safe="/;:")
if request is not None:
url = request.build_absolute_uri(url)
return url
def datetime_to_utc(date):
"""Returns datetime in UTC without timezone info
Args:
date (datetime.datetime): input datetime with timezone info
Returns:
datetime.datetime: datetime in UTC without timezone info
"""
if date.tzinfo and date.tzinfo != timezone.utc:
return date.astimezone(tz=timezone.utc)
else:
return date
def parse_iso8601_date_to_utc(iso_date: str) -> datetime:
"""Given an ISO 8601 datetime string, parse the result as UTC datetime.
Returns:
a timezone-aware datetime representing the parsed date
Raises:
swh.web.common.exc.BadInputExc: provided date does not respect ISO 8601 format
Samples:
- 2016-01-12
- 2016-01-12T09:19:12+0100
- 2007-01-14T20:34:22Z
"""
try:
date = parse_date(iso_date)
return datetime_to_utc(date)
except ParseError as e:
raise BadInputExc(e)
def shorten_path(path):
"""Shorten the given path: for each hash present, only return the first
8 characters followed by an ellipsis"""
sha256_re = r"([0-9a-f]{8})[0-9a-z]{56}"
sha1_re = r"([0-9a-f]{8})[0-9a-f]{32}"
ret = re.sub(sha256_re, r"\1...", path)
return re.sub(sha1_re, r"\1...", ret)
def format_utc_iso_date(iso_date, fmt="%d %B %Y, %H:%M:%S UTC"):
"""Turns a string representation of an ISO 8601 datetime string
to UTC and format it into a more human readable one.
For instance, from the following input
string: '2017-05-04T13:27:13+02:00' the following one
is returned: '04 May 2017, 11:27 UTC'.
Custom format string may also be provided
as parameter
Args:
iso_date (str): a string representation of an ISO 8601 date
fmt (str): optional date formatting string
Returns:
str: a formatted string representation of the input iso date
"""
if not iso_date:
return iso_date
date = parse_iso8601_date_to_utc(iso_date)
return date.strftime(fmt)
def gen_path_info(path):
"""Function to generate path data navigation for use
with a breadcrumb in the swh web ui.
For instance, from a path /folder1/folder2/folder3,
it returns the following list::
[{'name': 'folder1', 'path': 'folder1'},
{'name': 'folder2', 'path': 'folder1/folder2'},
{'name': 'folder3', 'path': 'folder1/folder2/folder3'}]
Args:
path: a filesystem path
Returns:
list: a list of path data for navigation as illustrated above.
"""
path_info = []
if path:
sub_paths = path.strip("/").split("/")
path_from_root = ""
for p in sub_paths:
path_from_root += "/" + p
path_info.append({"name": p, "path": path_from_root.strip("/")})
return path_info
def parse_rst(text, report_level=2):
"""
Parse a reStructuredText string with docutils.
Args:
text (str): string with reStructuredText markups in it
report_level (int): level of docutils report messages to print
(1 info 2 warning 3 error 4 severe 5 none)
Returns:
docutils.nodes.document: a parsed docutils document
"""
parser = docutils.parsers.rst.Parser()
components = (docutils.parsers.rst.Parser,)
settings = docutils.frontend.OptionParser(
components=components
).get_default_values()
settings.report_level = report_level
document = docutils.utils.new_document("rst-doc", settings=settings)
parser.parse(text, document)
return document
def get_client_ip(request):
"""
Return the client IP address from an incoming HTTP request.
Args:
request (django.http.HttpRequest): the incoming HTTP request
Returns:
str: The client IP address
"""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0]
else:
ip = request.META.get("REMOTE_ADDR")
return ip
def is_swh_web_development(request: HttpRequest) -> bool:
"""Indicate if we are running a development version of swh-web."""
site_base_url = request.build_absolute_uri("/")
return any(
host in site_base_url for host in ("localhost", "127.0.0.1", "testserver")
)
def is_swh_web_staging(request: HttpRequest) -> bool:
"""Indicate if we are running a staging version of swh-web."""
config = get_config()
site_base_url = request.build_absolute_uri("/")
return any(
server_name in site_base_url for server_name in config["staging_server_names"]
)
def is_swh_web_production(request: HttpRequest) -> bool:
"""Indicate if we are running the public production version of swh-web."""
return SWH_WEB_SERVER_NAME in request.build_absolute_uri("/")
browsers_supported_image_mimes = set(
[
"image/gif",
"image/png",
"image/jpeg",
"image/bmp",
"image/webp",
"image/svg",
"image/svg+xml",
]
)
def context_processor(request):
"""
Django context processor used to inject variables
in all swh-web templates.
"""
config = get_config()
if (
hasattr(request, "user")
and request.user.is_authenticated
and not hasattr(request.user, "backend")
):
# To avoid django.template.base.VariableDoesNotExist errors
# when rendering templates when standard Django user is logged in.
request.user.backend = "django.contrib.auth.backends.ModelBackend"
return {
"swh_object_icons": swh_object_icons,
"available_languages": None,
"swh_client_config": config["client_config"],
"oidc_enabled": bool(config["keycloak"]["server_url"]),
"browsers_supported_image_mimes": browsers_supported_image_mimes,
"keycloak": config["keycloak"],
"site_base_url": request.build_absolute_uri("/"),
"DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"],
"status": config["status"],
"swh_web_dev": is_swh_web_development(request),
"swh_web_staging": is_swh_web_staging(request),
"swh_web_prod": is_swh_web_production(request),
"swh_web_version": get_distribution("swh.web").version,
"iframe_mode": False,
"ADMIN_LIST_DEPOSIT_PERMISSION": ADMIN_LIST_DEPOSIT_PERMISSION,
"ADD_FORGE_MODERATOR_PERMISSION": ADD_FORGE_MODERATOR_PERMISSION,
- "FEATURES": get_config()["features"],
"MAILMAP_ADMIN_PERMISSION": MAILMAP_ADMIN_PERMISSION,
"lang": "en",
"sidebar_state": request.COOKIES.get("sidebar-state", "expanded"),
"SWH_DJANGO_APPS": settings.SWH_DJANGO_APPS,
}
def resolve_branch_alias(
snapshot: Dict[str, Any], branch: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""
Resolve branch alias in snapshot content.
Args:
snapshot: a full snapshot content
branch: a branch alias contained in the snapshot
Returns:
The real snapshot branch that got aliased.
"""
while branch and branch["target_type"] == "alias":
if branch["target"] in snapshot["branches"]:
branch = snapshot["branches"][branch["target"]]
else:
from swh.web.common import archive
snp = archive.lookup_snapshot(
snapshot["id"], branches_from=branch["target"], branches_count=1
)
if snp and branch["target"] in snp["branches"]:
branch = snp["branches"][branch["target"]]
else:
branch = None
return branch
class _NoHeaderHTMLTranslator(HTMLTranslator):
"""
Docutils translator subclass to customize the generation of HTML
from reST-formatted docstrings
"""
def __init__(self, document):
super().__init__(document)
self.body_prefix = []
self.body_suffix = []
_HTML_WRITER = Writer()
_HTML_WRITER.translator_class = _NoHeaderHTMLTranslator
def rst_to_html(rst: str) -> str:
"""
Convert reStructuredText document into HTML.
Args:
rst: A string containing a reStructuredText document
Returns:
Body content of the produced HTML conversion.
"""
settings = {
"initial_header_level": 2,
"halt_level": 4,
"traceback": True,
"file_insertion_enabled": False,
"raw_enabled": False,
}
pp = publish_parts(rst, writer=_HTML_WRITER, settings_overrides=settings)
return f'<div class="swh-rst">{pp["html_body"]}</div>'
def prettify_html(html: str) -> str:
"""
Prettify an HTML document.
Args:
html: Input HTML document
Returns:
The prettified HTML document
"""
return BeautifulSoup(html, "lxml").prettify()
def django_cache(
timeout: int = DEFAULT_TIMEOUT,
catch_exception: bool = False,
exception_return_value: Any = None,
invalidate_cache_pred: Callable[[Any], bool] = lambda val: False,
):
"""Decorator to put the result of a function call in Django cache,
subsequent calls will directly return the cached value.
Args:
timeout: The number of seconds value will be hold in cache
catch_exception: If :const:`True`, any thrown exception by
the decorated function will be caught and not reraised
exception_return_value: The value to return if previous
parameter is set to :const:`True`
invalidate_cache_pred: A predicate function enabling to
invalidate the cache under certain conditions, decorated
function will then be called again
Returns:
The returned value of the decorated function for the specified
parameters
"""
def inner(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
func_args = args + (0,) + tuple(sorted(kwargs.items()))
cache_key = str(hash((func.__module__, func.__name__) + func_args))
ret = cache.get(cache_key)
if ret is None or invalidate_cache_pred(ret):
try:
ret = func(*args, **kwargs)
except Exception as exc:
if catch_exception:
sentry_capture_exception(exc)
return exception_return_value
else:
raise
else:
cache.set(cache_key, ret, timeout=timeout)
return ret
return wrapper
return inner
def _deposits_list_url(
deposits_list_base_url: str, page_size: int, username: Optional[str]
) -> str:
params = {"page_size": str(page_size)}
if username is not None:
params["username"] = username
return f"{deposits_list_base_url}?{urllib.parse.urlencode(params)}"
def get_deposits_list(username: Optional[str] = None) -> List[Dict[str, Any]]:
"""Return the list of software deposits using swh-deposit API"""
config = get_config()["deposit"]
private_api_url = config["private_api_url"].rstrip("/") + "/"
deposits_list_base_url = private_api_url + "deposits"
deposits_list_auth = HTTPBasicAuth(
config["private_api_user"], config["private_api_password"]
)
deposits_list_url = _deposits_list_url(
deposits_list_base_url, page_size=1, username=username
)
nb_deposits = requests.get(
deposits_list_url, auth=deposits_list_auth, timeout=30
).json()["count"]
@django_cache(invalidate_cache_pred=lambda data: data["count"] != nb_deposits)
def _get_deposits_data():
deposits_list_url = _deposits_list_url(
deposits_list_base_url, page_size=nb_deposits, username=username
)
return requests.get(
deposits_list_url,
auth=deposits_list_auth,
timeout=30,
).json()
deposits_data = _get_deposits_data()
return deposits_data["results"]
_origin_visit_types_cache_timeout = 24 * 60 * 60 # 24 hours
@django_cache(
timeout=_origin_visit_types_cache_timeout,
catch_exception=True,
exception_return_value=[],
)
def origin_visit_types() -> List[str]:
"""Return the exhaustive list of visit types for origins
ingested into the archive.
"""
return sorted(search().visit_types_count().keys())
def redirect_to_new_route(request, new_route, permanent=True):
"""Redirect a request to another route with url args and query parameters
eg: /origin/<url:url-val>/log?path=test can be redirected as
/log?url=<url-val>&path=test. This can be used to deprecate routes
"""
request_path = resolve(request.path_info)
args = {**request_path.kwargs, **request.GET.dict()}
return redirect(
reverse(new_route, query_params=args),
permanent=permanent,
)
diff --git a/swh/web/config.py b/swh/web/config.py
index d44a0170..db0c289f 100644
--- a/swh/web/config.py
+++ b/swh/web/config.py
@@ -1,245 +1,237 @@
# Copyright (C) 2017-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import os
from typing import Any, Dict
from swh.core import config
from swh.counters import get_counters
from swh.indexer.storage import get_indexer_storage
from swh.scheduler import get_scheduler
from swh.search import get_search
from swh.storage import get_storage
from swh.vault import get_vault
from swh.web import settings
SWH_WEB_SERVER_NAME = "archive.softwareheritage.org"
SWH_WEB_INTERNAL_SERVER_NAME = "archive.internal.softwareheritage.org"
SWH_WEB_STAGING_SERVER_NAMES = [
"webapp.staging.swh.network",
"webapp.internal.staging.swh.network",
]
SETTINGS_DIR = os.path.dirname(settings.__file__)
DEFAULT_CONFIG = {
"allowed_hosts": ("list", []),
"storage": (
"dict",
{
"cls": "remote",
"url": "http://127.0.0.1:5002/",
"timeout": 10,
},
),
"indexer_storage": (
"dict",
{
"cls": "remote",
"url": "http://127.0.0.1:5007/",
"timeout": 1,
},
),
"counters": (
"dict",
{
"cls": "remote",
"url": "http://127.0.0.1:5011/",
"timeout": 1,
},
),
"search": (
"dict",
{
"cls": "remote",
"url": "http://127.0.0.1:5010/",
"timeout": 10,
},
),
"search_config": (
"dict",
{
"metadata_backend": "swh-indexer-storage",
}, # or "swh-search"
),
"log_dir": ("string", "/tmp/swh/log"),
"debug": ("bool", False),
"serve_assets": ("bool", False),
"host": ("string", "127.0.0.1"),
"port": ("int", 5004),
"secret_key": ("string", "development key"),
# do not display code highlighting for content > 1MB
"content_display_max_size": ("int", 5 * 1024 * 1024),
"snapshot_content_max_size": ("int", 1000),
"throttling": (
"dict",
{
"cache_uri": None, # production: memcached as cache (127.0.0.1:11211)
# development: in-memory cache so None
"scopes": {
"swh_api": {
"limiter_rate": {"default": "120/h"},
"exempted_networks": ["127.0.0.0/8"],
},
"swh_api_origin_search": {
"limiter_rate": {"default": "10/m"},
"exempted_networks": ["127.0.0.0/8"],
},
"swh_vault_cooking": {
"limiter_rate": {"default": "120/h", "GET": "60/m"},
"exempted_networks": ["127.0.0.0/8"],
},
"swh_save_origin": {
"limiter_rate": {"default": "120/h", "POST": "10/h"},
"exempted_networks": ["127.0.0.0/8"],
},
"swh_api_origin_visit_latest": {
"limiter_rate": {"default": "700/m"},
"exempted_networks": ["127.0.0.0/8"],
},
},
},
),
"vault": (
"dict",
{
"cls": "remote",
"args": {
"url": "http://127.0.0.1:5005/",
},
},
),
"scheduler": ("dict", {"cls": "remote", "url": "http://127.0.0.1:5008/"}),
"development_db": ("string", os.path.join(SETTINGS_DIR, "db.sqlite3")),
"test_db": ("dict", {"name": "swh-web-test"}),
"production_db": ("dict", {"name": "swh-web"}),
"deposit": (
"dict",
{
"private_api_url": "https://deposit.softwareheritage.org/1/private/",
"private_api_user": "swhworker",
"private_api_password": "some-password",
},
),
"e2e_tests_mode": ("bool", False),
"es_workers_index_url": ("string", ""),
"history_counters_url": (
"string",
(
"http://counters1.internal.softwareheritage.org:5011"
"/counters_history/history.json"
),
),
"client_config": ("dict", {}),
"keycloak": ("dict", {"server_url": "", "realm_name": ""}),
"graph": (
"dict",
{
"server_url": "http://graph.internal.softwareheritage.org:5009/graph/",
"max_edges": {"staff": 0, "user": 100000, "anonymous": 1000},
},
),
"status": (
"dict",
{
"server_url": "https://status.softwareheritage.org/",
"json_path": "1.0/status/578e5eddcdc0cc7951000520",
},
),
"counters_backend": ("string", "swh-storage"), # or "swh-counters"
"staging_server_names": ("list", SWH_WEB_STAGING_SERVER_NAMES),
"instance_name": ("str", "archive-test.softwareheritage.org"),
"give": ("dict", {"public_key": "", "token": ""}),
"features": ("dict", {"add_forge_now": True}),
"add_forge_now": ("dict", {"email_address": "add-forge-now@example.com"}),
"swh_django_apps": (
"list",
[
"inbound_email",
"api",
"auth",
"browse",
"add_forge_now",
"mailmap",
"save_code_now",
],
),
}
swhweb_config: Dict[str, Any] = {}
def get_config(config_file="web/web"):
"""Read the configuration file `config_file`.
If an environment variable SWH_CONFIG_FILENAME is defined, this
takes precedence over the config_file parameter.
In any case, update the app with parameters (secret_key, conf)
and return the parsed configuration as a dict.
If no configuration file is provided, return a default
configuration.
"""
if not swhweb_config:
config_filename = os.environ.get("SWH_CONFIG_FILENAME")
if config_filename:
config_file = config_filename
cfg = config.load_named_config(config_file, DEFAULT_CONFIG)
swhweb_config.update(cfg)
config.prepare_folders(swhweb_config, "log_dir")
if swhweb_config.get("search"):
swhweb_config["search"] = get_search(**swhweb_config["search"])
else:
swhweb_config["search"] = None
swhweb_config["storage"] = get_storage(**swhweb_config["storage"])
swhweb_config["vault"] = get_vault(**swhweb_config["vault"])
swhweb_config["indexer_storage"] = get_indexer_storage(
**swhweb_config["indexer_storage"]
)
swhweb_config["scheduler"] = get_scheduler(**swhweb_config["scheduler"])
swhweb_config["counters"] = get_counters(**swhweb_config["counters"])
return swhweb_config
def search():
"""Return the current application's search."""
return get_config()["search"]
def storage():
"""Return the current application's storage."""
return get_config()["storage"]
def vault():
"""Return the current application's vault."""
return get_config()["vault"]
def indexer_storage():
"""Return the current application's indexer storage."""
return get_config()["indexer_storage"]
def scheduler():
"""Return the current application's scheduler."""
return get_config()["scheduler"]
def counters():
"""Return the current application's counters."""
return get_config()["counters"]
-
-
-def is_feature_enabled(feature_name: str) -> bool:
- """Determine whether a feature is enabled or not. If feature_name is not found at all,
- it's considered disabled.
-
- """
- return get_config()["features"].get(feature_name, False)
diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html
index 86f89492..8ae81521 100644
--- a/swh/web/templates/layout.html
+++ b/swh/web/templates/layout.html
@@ -1,315 +1,315 @@
{% comment %}
Copyright (C) 2015-2022 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
<!DOCTYPE html>
{% load js_reverse %}
{% load static %}
{% load render_bundle from webpack_loader %}
{% load swh_templatetags %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}{% endblock %}</title>
{% render_bundle 'vendors' %}
{% render_bundle 'webapp' %}
{% render_bundle 'guided_tour' %}
<script>
/*
@licstart The following is the entire license notice for the JavaScript code in this page.
Copyright (C) 2015-2022 The Software Heritage developers
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
@licend The above is the entire license notice for the JavaScript code in this page.
*/
</script>
<script>
SWH_CONFIG = {{swh_client_config|jsonify}};
swh.webapp.sentryInit(SWH_CONFIG.sentry_dsn);
</script>
<script src="{% url 'js_reverse' %}" type="text/javascript"></script>
<script>
swh.webapp.setSwhObjectIcons({{ swh_object_icons|jsonify }});
</script>
{{ request.user.is_authenticated|json_script:"swh_user_logged_in" }}
{% include "includes/favicon.html" %}
{% block header %}{% endblock %}
{% if swh_web_prod %}
<!-- Matomo -->
<script type="text/javascript">
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
(function() {
var u="https://piwik.inria.fr/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '59']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
{% endif %}
</head>
<body class="hold-transition layout-fixed sidebar-mini {% if sidebar_state == 'collapsed' %} sidebar-collapse {% endif %}">
<a id="top"></a>
<div class="wrapper">
<div class="swh-top-bar">
<ul>
<li class="swh-position-left">
<div id="swh-full-width-switch-container" class="custom-control custom-switch d-none d-lg-block d-xl-block">
<input type="checkbox" class="custom-control-input" id="swh-full-width-switch" onclick="swh.webapp.fullWidthToggled(event)">
<label class="custom-control-label font-weight-normal" for="swh-full-width-switch">Full width</label>
</div>
</li>
<li>
<a href="https://www.softwareheritage.org">Home</a>
</li>
<li>
<a href="https://forge.softwareheritage.org/">Development</a>
</li>
<li>
<a href="https://docs.softwareheritage.org/devel/">Documentation</a>
</li>
<li>
<a class="swh-donate-link" href="https://www.softwareheritage.org/donate">Donate</a>
</li>
<li class="swh-position-right">
<a href="{{ status.server_url }}" target="_blank"
class="swh-current-status mr-3 d-none d-lg-inline-block d-xl-inline-block">
<span id="swh-current-status-description">Operational</span>
<i class="swh-current-status-indicator green"></i>
</a>
{% url 'logout' as logout_url %}
{% if user.is_authenticated %}
Logged in as
{% if 'OIDC' in user.backend %}
<a id="swh-login" href="{% url 'oidc-profile' %}"><strong>{{ user.username }}</strong></a>,
<a href= "{% url 'oidc-logout' %}?next_path={% url 'logout' %}?remote_user=1">logout</a>
{% else %}
<strong id="swh-login">{{ user.username }}</strong>,
<a href="{{ logout_url }}">logout</a>
{% endif %}
{% elif oidc_enabled %}
{% if request.path != logout_url %}
<a id="swh-login" href="{% url 'oidc-login' %}?next_path={{ request.build_absolute_uri }}">login</a>
{% else %}
<a id="swh-login" href="{% url 'oidc-login' %}">login</a>
{% endif %}
{% else %}
{% if request.path != logout_url %}
<a id="swh-login" href="{% url 'login' %}?next={{ request.build_absolute_uri }}">login</a>
{% else %}
<a id="swh-login" href="{% url 'login' %}">login</a>
{% endif %}
{% endif %}
</li>
</ul>
</div>
<div class="swh-banner">
{% include "misc/hiring-banner.html" %}
</div>
<nav class="main-header navbar navbar-expand-lg navbar-light navbar-static-top" id="swh-navbar">
<div class="navbar-header">
<a class="nav-link swh-push-menu" data-widget="pushmenu" data-enable-remember="true" href="#">
<i class="mdi mdi-24px mdi-menu mdi-fw" aria-hidden="true"></i>
</a>
</div>
<div class="navbar" style="width: 94%;">
<div class="swh-navbar-content">
{% block navbar-content %}{% endblock %}
{% if request.resolver_match.url_name != 'swh-web-homepage' and request.resolver_match.url_name != 'browse-search' %}
<form class="form-horizontal d-none d-md-flex input-group swh-search-navbar needs-validation"
id="swh-origins-search-top">
<input class="form-control"
placeholder="Enter a SWHID to resolve or keyword(s) to search for in origin URLs"
type="text" id="swh-origins-search-top-input"
oninput="swh.webapp.validateSWHIDInput(this)" required/>
<div class="input-group-append">
<button class="btn btn-primary" type="submit">
<i class="swh-search-icon mdi mdi-24px mdi-magnify" aria-hidden="true"></i>
</button>
</div>
</form>
{% endif %}
</div>
</div>
</nav>
</div>
<aside class="swh-sidebar main-sidebar sidebar-no-expand sidebar-light-primary elevation-4 swh-sidebar-{{ sidebar_state }}">
<a href="{% url 'swh-web-homepage' %}" class="brand-link">
<img class="brand-image" src="{% static 'img/swh-logo.png' %}">
<div class="brand-text sitename" href="{% url 'swh-web-homepage' %}">
<span class="first-word">Software</span> <span class="second-word">Heritage</span>
</div>
</a>
<a href="/" class="swh-words-logo">
<div class="swh-words-logo-swh">
<span class="first-word">Software</span>
<span class="second-word">Heritage</span>
</div>
<span>Archive</span>
</a>
<div class="sidebar">
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
<li class="nav-header">Features</li>
<li class="nav-item swh-search-item" title="Search archived software">
<a href="{% url 'browse-search' %}" class="nav-link swh-search-link">
<i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-magnify"></i>
<p>Search</p>
</a>
</li>
<li class="nav-item swh-vault-item" title="Download archived software from the Vault">
<a href="{% url 'browse-vault' %}" class="nav-link swh-vault-link">
<i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-download"></i>
<p>Downloads</p>
</a>
</li>
{% if "swh.web.save_code_now" in SWH_DJANGO_APPS %}
<li class="nav-item swh-origin-save-item" title="Request the saving of a software origin into the archive">
<a href="{% url 'origin-save' %}" class="nav-link swh-origin-save-link">
<i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-camera"></i>
<p>Save code now</p>
</a>
</li>
{% endif %}
- {% if FEATURES.add_forge_now %}
+ {% if "swh.web.add_forge_now" in SWH_DJANGO_APPS %}
<li class="nav-item swh-add-forge-now-item" title="Request adding a new forge listing">
<a href="{% url 'forge-add-create' %}" class="nav-link swh-add-forge-now-link">
<i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-anvil"></i>
<p>Add forge now</p>
</a>
</li>
{% endif %}
<li class="nav-item swh-help-item" title="How to browse the archive ?">
<a href="#" class="nav-link swh-help-link" onclick="swh.guided_tour.guidedTourButtonClick(event)">
<i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-help-circle"></i>
<p>Help</p>
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-header">Administration</li>
{% if "swh.web.save_code_now" in SWH_DJANGO_APPS and user.is_staff %}
<li class="nav-item swh-origin-save-admin-item" title="Save code now administration">
<a href="{% url 'admin-origin-save-requests' %}" class="nav-link swh-origin-save-admin-link">
<i style="color: #fecd1b;" class="nav-icon mdi mdi-24px mdi-camera"></i>
<p>Save code now</p>
</a>
</li>
{% endif %}
- {% if FEATURES.add_forge_now %}
+ {% if "swh.web.add_forge_now" in SWH_DJANGO_APPS %}
{% if user.is_staff or ADD_FORGE_MODERATOR_PERMISSION in user.get_all_permissions %}
<li class="nav-item swh-add-forge-now-moderation-item" title="Add forge now moderation">
<a href="{% url 'add-forge-now-requests-moderation' %}" class="nav-link swh-add-forge-now-moderation-link">
<i style="color: #fecd1b;" class="nav-icon mdi mdi-24px mdi-anvil"></i>
<p>Add forge now</p>
</a>
</li>
{% endif %}
{% endif %}
{% if user.is_staff or ADMIN_LIST_DEPOSIT_PERMISSION in user.get_all_permissions %}
<li class="nav-item swh-deposit-admin-item" title="Deposit administration">
<a href="{% url 'admin-deposit' %}" class="nav-link swh-deposit-admin-link">
<i style="color: #fecd1b;" class="nav-icon mdi mdi-24px mdi-folder-upload"></i>
<p>Deposit</p>
</a>
</li>
{% endif %}
{% if "swh.web.mailmap" in SWH_DJANGO_APPS and MAILMAP_ADMIN_PERMISSION in user.get_all_permissions %}
<li class="nav-item swh-mailmap-admin-item" title="Mailmap administration">
<a href="{% url 'admin-mailmap' %}" class="nav-link swh-mailmap-admin-link">
<i style="color: #fecd1b;" class="nav-icon mdi mdi-24px mdi-email"></i>
<p>Mailmap</p>
</a>
</li>
{% endif %}
{% endif %}
</ul>
</nav>
</div>
</aside>
<div class="content-wrapper">
<section class="content">
<div class="container" id="swh-web-content">
{% if swh_web_staging %}
<div class="swh-corner-ribbon">Staging<br/>v{{ swh_web_version }}</div>
{% elif swh_web_dev %}
<div class="swh-corner-ribbon">Development<br/>v{{ swh_web_version|split:"+"|first }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</section>
</div>
{% include "includes/global-modals.html" %}
<footer class="footer">
<div class="container text-center">
<a href="https://www.softwareheritage.org">Software Heritage</a> —
Copyright (C) 2015–{% now "Y" %}, The Software Heritage developers.
License: <a href="https://www.gnu.org/licenses/agpl.html">GNU
AGPLv3+</a>. <br/> The source code of Software Heritage <em>itself</em>
is available on
our <a href="https://forge.softwareheritage.org/">development
forge</a>. <br/> The source code files <em>archived</em> by Software
Heritage are available under their own copyright and licenses. <br/>
<span class="link-color">Terms of use: </span>
<a href="https://www.softwareheritage.org/legal/bulk-access-terms-of-use/">Archive access</a>,
<a href="https://www.softwareheritage.org/legal/api-terms-of-use/">API</a>-
<a href="https://www.softwareheritage.org/contact/">Contact</a>-
<a href="{% url 'jslicenses' %}" rel="jslicense">JavaScript license information</a>-
<a href="{% url 'api-1-homepage' %}">Web API</a><br/>
{% if "production" not in DJANGO_SETTINGS_MODULE %}
swh-web v{{ swh_web_version }}
{% endif %}
</div>
</footer>
<div id="back-to-top">
<a href="#top"><img alt="back to top" src="{% static 'img/arrow-up-small.png' %}" /></a>
</div>
<script>
swh.webapp.setContainerFullWidth();
var statusServerURL = {{ status.server_url|jsonify }};
var statusJsonPath = {{ status.json_path|jsonify }};
swh.webapp.initStatusWidget(statusServerURL + statusJsonPath);
</script>
</body>
</html>
diff --git a/swh/web/tests/add_forge_now/__init__.py b/swh/web/tests/add_forge_now/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/swh/web/tests/api/views/test_add_forge_now.py b/swh/web/tests/add_forge_now/test_api_views.py
similarity index 99%
rename from swh/web/tests/api/views/test_add_forge_now.py
rename to swh/web/tests/add_forge_now/test_api_views.py
index adeb0ad1..7466c767 100644
--- a/swh/web/tests/api/views/test_add_forge_now.py
+++ b/swh/web/tests/add_forge_now/test_api_views.py
@@ -1,629 +1,629 @@
# Copyright (C) 2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import datetime
import threading
import time
from typing import Dict
from urllib.parse import urlencode, urlparse
import iso8601
import pytest
from swh.web.add_forge_now.models import Request, RequestHistory
from swh.web.common.utils import reverse
from swh.web.config import get_config
from swh.web.inbound_email.utils import get_address_for_pk
from swh.web.tests.utils import (
check_api_get_responses,
check_api_post_response,
check_http_get_response,
check_http_post_response,
)
@pytest.mark.django_db
def test_add_forge_request_create_anonymous_user(api_client):
url = reverse("api-1-add-forge-request-create")
check_api_post_response(api_client, url, status_code=403)
@pytest.mark.django_db
def test_add_forge_request_create_empty(api_client, regular_user):
api_client.force_login(regular_user)
url = reverse("api-1-add-forge-request-create")
resp = check_api_post_response(api_client, url, status_code=400)
assert '"forge_type"' in resp.data["reason"]
ADD_FORGE_DATA_FORGE1: Dict = {
"forge_type": "gitlab",
"forge_url": "https://gitlab.example.org",
"forge_contact_email": "admin@gitlab.example.org",
"forge_contact_name": "gitlab.example.org admin",
"forge_contact_comment": "user marked as owner in forge members",
"submitter_forward_username": True,
}
ADD_FORGE_DATA_FORGE2: Dict = {
"forge_type": "gitea",
"forge_url": "https://gitea.example.org",
"forge_contact_email": "admin@gitea.example.org",
"forge_contact_name": "gitea.example.org admin",
"forge_contact_comment": "user marked as owner in forge members",
"submitter_forward_username": True,
}
ADD_FORGE_DATA_FORGE3: Dict = {
"forge_type": "heptapod",
"forge_url": "https://heptapod.host/",
"forge_contact_email": "admin@example.org",
"forge_contact_name": "heptapod admin",
"forge_contact_comment": "", # authorized empty or null comment
"submitter_forward_username": False,
}
ADD_FORGE_DATA_FORGE4: Dict = {
**ADD_FORGE_DATA_FORGE3,
"forge_url": "https://heptapod2.host/",
"submitter_forward_username": "on",
}
ADD_FORGE_DATA_FORGE5: Dict = {
**ADD_FORGE_DATA_FORGE3,
"forge_url": "https://heptapod3.host/",
"submitter_forward_username": "off",
}
def inbound_email_for_pk(pk: int) -> str:
"""Check that the inbound email matches the one expected for the given pk"""
base_address = get_config()["add_forge_now"]["email_address"]
return get_address_for_pk(
salt="swh_web_add_forge_now", base_address=base_address, pk=pk
)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize(
"add_forge_data",
[
ADD_FORGE_DATA_FORGE1,
ADD_FORGE_DATA_FORGE2,
ADD_FORGE_DATA_FORGE3,
ADD_FORGE_DATA_FORGE4,
],
)
def test_add_forge_request_create_success_post(
api_client, regular_user, add_forge_data
):
api_client.force_login(regular_user)
url = reverse("api-1-add-forge-request-create")
date_before = datetime.datetime.now(tz=datetime.timezone.utc)
resp = check_api_post_response(
api_client,
url,
data=add_forge_data,
status_code=201,
)
date_after = datetime.datetime.now(tz=datetime.timezone.utc)
consent = add_forge_data["submitter_forward_username"]
# map the expected result with what's expectedly read from the db to ease comparison
expected_consent_bool = consent == "on" if isinstance(consent, str) else consent
assert resp.data == {
**add_forge_data,
"id": resp.data["id"],
"status": "PENDING",
"submission_date": resp.data["submission_date"],
"submitter_name": regular_user.username,
"submitter_email": regular_user.email,
"submitter_forward_username": expected_consent_bool,
"last_moderator": resp.data["last_moderator"],
"last_modified_date": resp.data["last_modified_date"],
"inbound_email_address": inbound_email_for_pk(resp.data["id"]),
"forge_domain": urlparse(add_forge_data["forge_url"]).netloc,
}
assert date_before < iso8601.parse_date(resp.data["submission_date"]) < date_after
request = Request.objects.all().last()
assert request.forge_url == add_forge_data["forge_url"]
assert request.submitter_name == regular_user.username
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_create_success_form_encoded(client, regular_user):
client.force_login(regular_user)
url = reverse("api-1-add-forge-request-create")
date_before = datetime.datetime.now(tz=datetime.timezone.utc)
resp = check_http_post_response(
client,
url,
request_content_type="application/x-www-form-urlencoded",
data=urlencode(ADD_FORGE_DATA_FORGE1),
status_code=201,
)
date_after = datetime.datetime.now(tz=datetime.timezone.utc)
assert resp.data == {
**ADD_FORGE_DATA_FORGE1,
"id": resp.data["id"],
"status": "PENDING",
"submission_date": resp.data["submission_date"],
"submitter_name": regular_user.username,
"submitter_email": regular_user.email,
"last_moderator": resp.data["last_moderator"],
"last_modified_date": resp.data["last_modified_date"],
"inbound_email_address": inbound_email_for_pk(1),
"forge_domain": urlparse(ADD_FORGE_DATA_FORGE1["forge_url"]).netloc,
}
assert date_before < iso8601.parse_date(resp.data["submission_date"]) < date_after
request = Request.objects.all()[0]
assert request.forge_url == ADD_FORGE_DATA_FORGE1["forge_url"]
assert request.submitter_name == regular_user.username
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_create_duplicate(api_client, regular_user):
api_client.force_login(regular_user)
url = reverse("api-1-add-forge-request-create")
check_api_post_response(
api_client,
url,
data=ADD_FORGE_DATA_FORGE1,
status_code=201,
)
check_api_post_response(
api_client,
url,
data=ADD_FORGE_DATA_FORGE1,
status_code=409,
)
requests = Request.objects.all()
assert len(requests) == 1
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_anonymous_user(api_client):
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(api_client, url, status_code=403)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_regular_user(api_client, regular_user):
api_client.force_login(regular_user)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(api_client, url, status_code=403)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_non_existent(api_client, add_forge_moderator):
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(api_client, url, status_code=400)
def create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE1):
api_client.force_login(regular_user)
url = reverse("api-1-add-forge-request-create")
return check_api_post_response(
api_client,
url,
data=data,
status_code=201,
)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_empty(api_client, regular_user, add_forge_moderator):
create_add_forge_request(api_client, regular_user)
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(api_client, url, status_code=400)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_missing_field(
api_client, regular_user, add_forge_moderator
):
create_add_forge_request(api_client, regular_user)
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(api_client, url, data={}, status_code=400)
check_api_post_response(
api_client, url, data={"new_status": "REJECTED"}, status_code=400
)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update(api_client, regular_user, add_forge_moderator):
create_add_forge_request(api_client, regular_user)
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(
api_client, url, data={"text": "updating request"}, status_code=200
)
check_api_post_response(
api_client,
url,
data={"new_status": "REJECTED", "text": "request rejected"},
status_code=200,
)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_invalid_new_status(
api_client, regular_user, add_forge_moderator
):
create_add_forge_request(api_client, regular_user)
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
check_api_post_response(
api_client,
url,
data={"new_status": "ACCEPTED", "text": "request accepted"},
status_code=400,
)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_update_status_concurrent(
api_client, regular_user, add_forge_moderator, mocker
):
_block_while_testing = mocker.patch(
- "swh.web.api.views.add_forge_now._block_while_testing"
+ "swh.web.add_forge_now.api_views._block_while_testing"
)
_block_while_testing.side_effect = lambda: time.sleep(1)
create_add_forge_request(api_client, regular_user)
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
worker_ended = False
def worker():
nonlocal worker_ended
check_api_post_response(
api_client,
url,
data={"new_status": "WAITING_FOR_FEEDBACK", "text": "waiting for message"},
status_code=200,
)
worker_ended = True
# this thread will first modify the request status to WAITING_FOR_FEEDBACK
thread = threading.Thread(target=worker)
thread.start()
# the other thread (slower) will attempt to modify the request status to REJECTED
# but it will not be allowed as the first faster thread already modified it
# and REJECTED state can not be reached from WAITING_FOR_FEEDBACK one
time.sleep(0.5)
check_api_post_response(
api_client,
url,
data={"new_status": "REJECTED", "text": "request accepted"},
status_code=400,
)
thread.join()
assert worker_ended
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_anonymous(api_client, regular_user):
url = reverse("api-1-add-forge-request-list")
resp = check_api_get_responses(api_client, url, status_code=200)
assert resp.data == []
create_add_forge_request(api_client, regular_user)
resp = check_api_get_responses(api_client, url, status_code=200)
add_forge_request = {
"forge_url": ADD_FORGE_DATA_FORGE1["forge_url"],
"forge_type": ADD_FORGE_DATA_FORGE1["forge_type"],
"status": "PENDING",
"submission_date": resp.data[0]["submission_date"],
"id": resp.data[0]["id"],
}
assert resp.data == [add_forge_request]
create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE2)
resp = check_api_get_responses(api_client, url, status_code=200)
other_forge_request = {
"forge_url": ADD_FORGE_DATA_FORGE2["forge_url"],
"forge_type": ADD_FORGE_DATA_FORGE2["forge_type"],
"status": "PENDING",
"submission_date": resp.data[0]["submission_date"],
"id": resp.data[0]["id"],
}
assert resp.data == [other_forge_request, add_forge_request]
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_moderator(
api_client, regular_user, add_forge_moderator
):
url = reverse("api-1-add-forge-request-list")
create_add_forge_request(api_client, regular_user)
create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE2)
api_client.force_login(add_forge_moderator)
resp = check_api_get_responses(api_client, url, status_code=200)
add_forge_request = {
**ADD_FORGE_DATA_FORGE1,
"status": "PENDING",
"submission_date": resp.data[1]["submission_date"],
"submitter_name": regular_user.username,
"submitter_email": regular_user.email,
"last_moderator": resp.data[1]["last_moderator"],
"last_modified_date": resp.data[1]["last_modified_date"],
"id": resp.data[1]["id"],
"inbound_email_address": inbound_email_for_pk(resp.data[1]["id"]),
"forge_domain": urlparse(ADD_FORGE_DATA_FORGE1["forge_url"]).netloc,
}
other_forge_request = {
**ADD_FORGE_DATA_FORGE2,
"status": "PENDING",
"submission_date": resp.data[0]["submission_date"],
"submitter_name": regular_user.username,
"submitter_email": regular_user.email,
"last_moderator": resp.data[0]["last_moderator"],
"last_modified_date": resp.data[0]["last_modified_date"],
"id": resp.data[0]["id"],
"inbound_email_address": inbound_email_for_pk(resp.data[0]["id"]),
"forge_domain": urlparse(ADD_FORGE_DATA_FORGE2["forge_url"]).netloc,
}
assert resp.data == [other_forge_request, add_forge_request]
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_pagination(
api_client, regular_user, api_request_factory
):
create_add_forge_request(api_client, regular_user)
create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE2)
url = reverse("api-1-add-forge-request-list", query_params={"per_page": 1})
resp = check_api_get_responses(api_client, url, 200)
assert len(resp.data) == 1
request = api_request_factory.get(url)
next_url = reverse(
"api-1-add-forge-request-list",
query_params={"page": 2, "per_page": 1},
request=request,
)
assert resp["Link"] == f'<{next_url}>; rel="next"'
resp = check_api_get_responses(api_client, next_url, 200)
assert len(resp.data) == 1
prev_url = reverse(
"api-1-add-forge-request-list",
query_params={"page": 1, "per_page": 1},
request=request,
)
assert resp["Link"] == f'<{prev_url}>; rel="previous"'
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_submitter_filtering(
api_client, regular_user, regular_user2
):
create_add_forge_request(api_client, regular_user)
create_add_forge_request(api_client, regular_user2, data=ADD_FORGE_DATA_FORGE2)
api_client.force_login(regular_user)
url = reverse(
"api-1-add-forge-request-list", query_params={"user_requests_only": 1}
)
resp = check_api_get_responses(api_client, url, status_code=200)
assert len(resp.data) == 1
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_get(api_client, regular_user, add_forge_moderator):
resp = create_add_forge_request(api_client, regular_user)
submission_date = resp.data["submission_date"]
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
api_client.force_login(add_forge_moderator)
check_api_post_response(
api_client,
url,
data={"new_status": "WAITING_FOR_FEEDBACK", "text": "waiting for message"},
status_code=200,
)
api_client.logout()
url = reverse("api-1-add-forge-request-get", url_args={"id": 1})
resp = check_api_get_responses(api_client, url, status_code=200)
assert resp.data == {
"request": {
"forge_url": ADD_FORGE_DATA_FORGE1["forge_url"],
"forge_type": ADD_FORGE_DATA_FORGE1["forge_type"],
"id": 1,
"status": "WAITING_FOR_FEEDBACK",
"submission_date": submission_date,
},
"history": [
{
"id": 1,
"actor_role": "SUBMITTER",
"date": resp.data["history"][0]["date"],
"new_status": "PENDING",
},
{
"id": 2,
"actor_role": "MODERATOR",
"date": resp.data["history"][1]["date"],
"new_status": "WAITING_FOR_FEEDBACK",
},
],
}
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_get_moderator(api_client, regular_user, add_forge_moderator):
resp = create_add_forge_request(api_client, regular_user)
submission_date = resp.data["submission_date"]
url = reverse("api-1-add-forge-request-update", url_args={"id": 1})
api_client.force_login(add_forge_moderator)
check_api_post_response(
api_client,
url,
data={"new_status": "WAITING_FOR_FEEDBACK", "text": "waiting for message"},
status_code=200,
)
url = reverse("api-1-add-forge-request-get", url_args={"id": 1})
resp = check_api_get_responses(api_client, url, status_code=200)
resp.data["history"] = [dict(history_item) for history_item in resp.data["history"]]
assert resp.data == {
"request": {
**ADD_FORGE_DATA_FORGE1,
"id": 1,
"status": "WAITING_FOR_FEEDBACK",
"submission_date": submission_date,
"submitter_name": regular_user.username,
"submitter_email": regular_user.email,
"last_moderator": add_forge_moderator.username,
"last_modified_date": resp.data["history"][1]["date"],
"inbound_email_address": inbound_email_for_pk(1),
"forge_domain": urlparse(ADD_FORGE_DATA_FORGE1["forge_url"]).netloc,
},
"history": [
{
"id": 1,
"text": "",
"actor": regular_user.username,
"actor_role": "SUBMITTER",
"date": resp.data["history"][0]["date"],
"new_status": "PENDING",
"message_source_url": None,
},
{
"id": 2,
"text": "waiting for message",
"actor": add_forge_moderator.username,
"actor_role": "MODERATOR",
"date": resp.data["history"][1]["date"],
"new_status": "WAITING_FOR_FEEDBACK",
"message_source_url": None,
},
],
}
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_get_moderator_message_source(
api_client, regular_user, add_forge_moderator
):
resp = create_add_forge_request(api_client, regular_user)
rh = RequestHistory(
request=Request.objects.get(pk=resp.data["id"]),
new_status="WAITING_FOR_FEEDBACK",
text="waiting for message",
actor=add_forge_moderator.username,
actor_role="MODERATOR",
message_source=b"test with a message source",
)
rh.save()
api_client.force_login(add_forge_moderator)
url = reverse("api-1-add-forge-request-get", url_args={"id": resp.data["id"]})
resp = check_api_get_responses(api_client, url, status_code=200)
resp.data["history"] = [dict(history_item) for history_item in resp.data["history"]]
# Check that the authentified moderator can't urlhack non-existent message sources
assert resp.data["history"][0]["message_source_url"] is None
empty_message_url = reverse(
"forge-add-message-source", url_args={"id": resp.data["history"][0]["id"]}
)
check_http_get_response(api_client, empty_message_url, status_code=404)
# Check that the authentified moderator can't urlhack non-existent message sources
non_existent_message_url = reverse(
"forge-add-message-source", url_args={"id": 9001}
)
check_http_get_response(api_client, non_existent_message_url, status_code=404)
# Check that the authentified moderator can access the message source when the url is
# given
message_source_url = resp.data["history"][-1]["message_source_url"]
assert message_source_url is not None
message_source_resp = check_http_get_response(
api_client, message_source_url, status_code=200, content_type="text/email"
)
# Check that the message source shows up as an attachment
assert message_source_resp.content == rh.message_source
disposition = message_source_resp["Content-Disposition"]
assert disposition.startswith("attachment; filename=")
assert disposition.endswith('.eml"')
# Check that a regular user can't access message sources
api_client.force_login(regular_user)
check_http_get_response(api_client, message_source_url, status_code=302)
api_client.force_login(add_forge_moderator)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_get_invalid(api_client):
url = reverse("api-1-add-forge-request-get", url_args={"id": 3})
check_api_get_responses(api_client, url, status_code=400)
diff --git a/swh/web/tests/add_forge_now/test_app.py b/swh/web/tests/add_forge_now/test_app.py
new file mode 100644
index 00000000..47cc92d8
--- /dev/null
+++ b/swh/web/tests/add_forge_now/test_app.py
@@ -0,0 +1,33 @@
+# Copyright (C) 2022 The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU Affero General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+import pytest
+
+from django.urls import get_resolver
+
+from swh.web.add_forge_now.urls import urlpatterns
+from swh.web.common.utils import reverse
+from swh.web.tests.django_asserts import assert_not_contains
+from swh.web.tests.utils import check_html_get_response
+
+
+@pytest.mark.django_db
+def test_add_forge_now_deactivate(client, staff_user, django_settings):
+ """Check Add forge now feature is deactivated when the swh.web.add_forge_now django
+ application is not in installed apps."""
+
+ django_settings.SWH_DJANGO_APPS = [
+ app for app in django_settings.SWH_DJANGO_APPS if app != "swh.web.add_forge_now"
+ ]
+
+ url = reverse("swh-web-homepage")
+ client.force_login(staff_user)
+ resp = check_html_get_response(client, url, status_code=200)
+ assert_not_contains(resp, "swh-add-forge-now-item")
+ assert_not_contains(resp, "swh-add-forge-now-moderation-item")
+
+ add_forge_now_view_names = set(urlpattern.name for urlpattern in urlpatterns)
+ all_view_names = set(get_resolver().reverse_dict.keys())
+ assert add_forge_now_view_names & all_view_names == set()
diff --git a/swh/web/tests/add_forge_now/test_views.py b/swh/web/tests/add_forge_now/test_views.py
index 48874ebf..e36658f4 100644
--- a/swh/web/tests/add_forge_now/test_views.py
+++ b/swh/web/tests/add_forge_now/test_views.py
@@ -1,207 +1,208 @@
# Copyright (C) 2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import json
import pytest
from swh.web.common.utils import reverse
-from swh.web.tests.api.views.test_add_forge_now import create_add_forge_request
from swh.web.tests.utils import check_http_get_response
+from .test_api_views import create_add_forge_request
+
NB_FORGE_TYPE = 2
NB_FORGES_PER_TYPE = 20
def create_add_forge_requests(client, regular_user, regular_user2):
requests = []
for i in range(NB_FORGES_PER_TYPE):
request = {
"forge_type": "gitlab",
"forge_url": f"https://gitlab.example{i:02d}.org",
"forge_contact_email": f"admin@gitlab.example{i:02d}.org",
"forge_contact_name": f"gitlab.example{i:02d}.org admin",
"forge_contact_comment": "user marked as owner in forge members",
}
create_add_forge_request(
client,
regular_user,
data=request,
)
requests.append(request)
request = {
"forge_type": "gitea",
"forge_url": f"https://gitea.example{i:02d}.org",
"forge_contact_email": f"admin@gitea.example{i:02d}.org",
"forge_contact_name": f"gitea.example{i:02d}.org admin",
"forge_contact_comment": "user marked as owner in forge members",
}
create_add_forge_request(
client,
regular_user2,
data=request,
)
requests.append(request)
return requests
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_datatables_no_parameters(
client, regular_user, regular_user2
):
create_add_forge_requests(client, regular_user, regular_user2)
url = reverse("add-forge-request-list-datatables")
resp = check_http_get_response(client, url, status_code=200)
data = json.loads(resp.content)
length = 10
assert data["draw"] == 0
assert data["recordsFiltered"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["recordsTotal"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert len(data["data"]) == length
# default ordering is by descending id
assert data["data"][0]["id"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["data"][-1]["id"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE - length + 1
assert "submitter_name" not in data["data"][0]
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_datatables(
client, regular_user, regular_user2, add_forge_moderator
):
create_add_forge_requests(client, regular_user, regular_user2)
length = 10
url = reverse(
"add-forge-request-list-datatables",
query_params={"draw": 1, "length": length, "start": 0},
)
client.force_login(regular_user)
resp = check_http_get_response(client, url, status_code=200)
data = json.loads(resp.content)
assert data["draw"] == 1
assert data["recordsFiltered"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["recordsTotal"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert len(data["data"]) == length
# default ordering is by descending id
assert data["data"][0]["id"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["data"][-1]["id"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE - length + 1
assert "submitter_name" not in data["data"][0]
client.force_login(add_forge_moderator)
resp = check_http_get_response(client, url, status_code=200)
data = json.loads(resp.content)
assert data["draw"] == 1
assert data["recordsFiltered"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["recordsTotal"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert len(data["data"]) == length
# default ordering is by descending id
assert data["data"][0]["id"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["data"][-1]["id"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE - length + 1
assert "submitter_name" in data["data"][0]
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_datatables_ordering(
client, regular_user, regular_user2
):
requests = create_add_forge_requests(client, regular_user, regular_user2)
requests_sorted = list(sorted(requests, key=lambda d: d["forge_url"]))
forge_urls_asc = [request["forge_url"] for request in requests_sorted]
forge_urls_desc = list(reversed(forge_urls_asc))
length = 10
for direction in ("asc", "desc"):
for i in range(4):
url = reverse(
"add-forge-request-list-datatables",
query_params={
"draw": 1,
"length": length,
"start": i * length,
"order[0][column]": 2,
"order[0][dir]": direction,
"columns[2][name]": "forge_url",
},
)
client.force_login(regular_user)
resp = check_http_get_response(client, url, status_code=200)
data = json.loads(resp.content)
assert data["draw"] == 1
assert data["recordsFiltered"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert data["recordsTotal"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert len(data["data"]) == length
page_forge_urls = [request["forge_url"] for request in data["data"]]
if direction == "asc":
expected_forge_urls = forge_urls_asc[i * length : (i + 1) * length]
else:
expected_forge_urls = forge_urls_desc[i * length : (i + 1) * length]
assert page_forge_urls == expected_forge_urls
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_datatables_search(client, regular_user, regular_user2):
create_add_forge_requests(client, regular_user, regular_user2)
url = reverse(
"add-forge-request-list-datatables",
query_params={
"draw": 1,
"length": NB_FORGES_PER_TYPE,
"start": 0,
"search[value]": "gitlab",
},
)
client.force_login(regular_user)
resp = check_http_get_response(client, url, status_code=200)
data = json.loads(resp.content)
assert data["draw"] == 1
assert data["recordsFiltered"] == NB_FORGES_PER_TYPE
assert data["recordsTotal"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert len(data["data"]) == NB_FORGES_PER_TYPE
page_forge_type = [request["forge_type"] for request in data["data"]]
assert page_forge_type == ["gitlab"] * NB_FORGES_PER_TYPE
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_add_forge_request_list_datatables_user_requests(
client, regular_user, regular_user2
):
create_add_forge_requests(client, regular_user, regular_user2)
url = reverse(
"add-forge-request-list-datatables",
query_params={
"draw": 1,
"length": NB_FORGES_PER_TYPE * NB_FORGE_TYPE,
"start": 0,
"user_requests_only": 1,
},
)
client.force_login(regular_user2)
resp = check_http_get_response(client, url, status_code=200)
data = json.loads(resp.content)
assert data["draw"] == 1
assert data["recordsFiltered"] == NB_FORGES_PER_TYPE
assert data["recordsTotal"] == NB_FORGE_TYPE * NB_FORGES_PER_TYPE
assert len(data["data"]) == NB_FORGES_PER_TYPE
page_forge_type = [request["forge_type"] for request in data["data"]]
assert page_forge_type == ["gitea"] * NB_FORGES_PER_TYPE
diff --git a/swh/web/tests/test_config.py b/swh/web/tests/test_config.py
deleted file mode 100644
index 571b3be0..00000000
--- a/swh/web/tests/test_config.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# Copyright (C) 2022 The Software Heritage developers
-# See the AUTHORS file at the top-level directory of this distribution
-# License: GNU General Public License version 3, or any later version
-# See top-level LICENSE file for more information
-
-import pytest
-
-from swh.web.config import get_config, is_feature_enabled
-
-
-@pytest.mark.parametrize(
- "feature_name",
- ["inexistant-feature", "awesome-stuff"],
-)
-def test_is_feature_enabled(feature_name):
- config = get_config()
- # by default, feature non configured are considered disabled
- assert is_feature_enabled(feature_name) is False
-
- for enabled in [True, False]:
- # Let's configure the feature
- config["features"] = {feature_name: enabled}
- # and check its configuration is properly read
- assert is_feature_enabled(feature_name) is enabled
diff --git a/swh/web/urls.py b/swh/web/urls.py
index 531daa0a..07b543d2 100644
--- a/swh/web/urls.py
+++ b/swh/web/urls.py
@@ -1,88 +1,85 @@
# Copyright (C) 2017-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from importlib import import_module
from django_js_reverse.views import urls_js
from django.conf import settings
from django.conf.urls import handler400, handler403, handler404, handler500, include
from django.contrib.auth.views import LogoutView
from django.contrib.staticfiles.views import serve
from django.shortcuts import render
from django.urls import re_path as url
from django.views.generic.base import RedirectView
from swh.web.browse.identifiers import swhid_browse
from swh.web.common.exc import (
swh_handle400,
swh_handle403,
swh_handle404,
swh_handle500,
)
from swh.web.common.utils import origin_visit_types
-from swh.web.config import get_config, is_feature_enabled
+from swh.web.config import get_config
swh_web_config = get_config()
favicon_view = RedirectView.as_view(
url="/static/img/icons/swh-logo-32x32.png", permanent=True
)
def _default_view(request):
return render(request, "homepage.html", {"visit_types": origin_visit_types()})
urlpatterns = [
url(r"^admin/", include("swh.web.admin.urls")),
url(r"^favicon\.ico/$", favicon_view),
url(r"^$", _default_view, name="swh-web-homepage"),
url(r"^jsreverse/$", urls_js, name="js_reverse"),
# keep legacy SWHID resolving URL with trailing slash for backward compatibility
url(
r"^(?P<swhid>(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)/$",
swhid_browse,
name="browse-swhid-legacy",
),
url(
r"^(?P<swhid>(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)$",
swhid_browse,
name="browse-swhid",
),
url(r"^", include("swh.web.misc.urls")),
url(r"^", include("swh.web.auth.views")),
url(r"^logout/$", LogoutView.as_view(template_name="logout.html"), name="logout"),
]
for app in settings.SWH_DJANGO_APPS:
try:
app_name = app.split(".")[-1]
app_urls = app + ".urls"
import_module(app_urls)
urlpatterns.append(url(r"^", include(app_urls)))
except ModuleNotFoundError:
pass
-if is_feature_enabled("add_forge_now"):
- urlpatterns += (url(r"^", include("swh.web.add_forge_now.views")),)
-
# allow to serve assets through django staticfiles
# even if settings.DEBUG is False
def insecure_serve(request, path, **kwargs):
return serve(request, path, insecure=True, **kwargs)
# enable to serve compressed assets through django development server
if swh_web_config["serve_assets"]:
static_pattern = r"^%s(?P<path>.*)/$" % settings.STATIC_URL[1:]
urlpatterns.append(url(static_pattern, insecure_serve))
handler400 = swh_handle400 # noqa
handler403 = swh_handle403 # noqa
handler404 = swh_handle404 # noqa
handler500 = swh_handle500 # noqa
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Jul 4, 11:39 AM (3 w, 2 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3452546
Attached To
rDWAPPS Web applications
Event Timeline
Log In to Comment