Skip to content

Commit

Permalink
feat(admin): Add Project.Observation behaviors
Browse files Browse the repository at this point in the history
Signed-off-by: Mike Fiedler <miketheman@gmail.com>
  • Loading branch information
miketheman committed Nov 6, 2023
1 parent 9d5c3ee commit 539000f
Show file tree
Hide file tree
Showing 8 changed files with 450 additions and 0 deletions.
14 changes: 14 additions & 0 deletions tests/unit/admin/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ def test_includeme():
traverse="/{project_name}/{version}",
domain=warehouse,
),
pretend.call(
"admin.project.observations",
"/admin/projects/{project_name}/observations/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.add_observation",
"/admin/projects/{project_name}/add_observation/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.journals",
"/admin/projects/{project_name}/journals/",
Expand Down
116 changes: 116 additions & 0 deletions tests/unit/admin/views/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@

from tests.common.db.oidc import GitHubPublisherFactory
from warehouse.admin.views import projects as views
from warehouse.observations.models import ObservationKind
from warehouse.packaging.models import Project, Role
from warehouse.packaging.tasks import update_release_description
from warehouse.search.tasks import reindex_project

from ....common.db.accounts import UserFactory
from ....common.db.observations import ObserverFactory
from ....common.db.packaging import (
JournalEntryFactory,
ProjectFactory,
ProjectObservationFactory,
ReleaseFactory,
RoleFactory,
)
Expand Down Expand Up @@ -101,6 +104,8 @@ def test_gets_project(self, db_request):
"MAX_PROJECT_SIZE": views.MAX_PROJECT_SIZE,
"ONE_GB": views.ONE_GB,
"UPLOAD_LIMIT_CAP": views.UPLOAD_LIMIT_CAP,
"observation_kinds": ObservationKind,
"observations": [],
}

def test_non_normalized_name(self, db_request):
Expand Down Expand Up @@ -347,6 +352,117 @@ def test_non_normalized_name(self, db_request):
views.journals_list(project, db_request)


class TestProjectObservationsList:
def test_with_page(self, db_request):
observer = ObserverFactory.create()
UserFactory.create(observer=observer)
project = ProjectFactory.create()
observations = ProjectObservationFactory.create_batch(
size=30, related=project, observer=observer
)

db_request.matchdict["project_name"] = project.normalized_name
db_request.GET["page"] = "2"
result = views.observations_list(project, db_request)

assert result == {
"observations": observations[25:],
"project": project,
}

def test_with_invalid_page(self, db_request):
project = ProjectFactory.create()
db_request.matchdict["project_name"] = project.normalized_name
db_request.GET["page"] = "not an integer"

with pytest.raises(HTTPBadRequest):
views.observations_list(project, db_request)


class TestProjectAddObservation:
def test_add_observation(self, db_request):
project = ProjectFactory.create()
observer = ObserverFactory.create()
user = UserFactory.create(observer=observer)
db_request.route_path = pretend.call_recorder(
lambda *a, **kw: "/admin/projects/"
)
db_request.matchdict["project_name"] = project.normalized_name
db_request.POST["kind"] = ObservationKind.IsSpam.value[0]
db_request.POST["summary"] = "This is a summary"
db_request.user = user

views.add_observation(project, db_request)

assert len(project.observations) == 1

def test_no_user_observer(self, db_request):
project = ProjectFactory.create()
user = UserFactory.create()
db_request.route_path = pretend.call_recorder(
lambda *a, **kw: "/admin/projects/"
)
db_request.matchdict["project_name"] = project.normalized_name
db_request.POST["kind"] = ObservationKind.IsSpam.value[0]
db_request.POST["summary"] = "This is a summary"
db_request.user = user

views.add_observation(project, db_request)

assert len(project.observations) == 1

def test_no_kind_errors(self):
project = pretend.stub(name="foo", normalized_name="foo")
request = pretend.stub(
POST={},
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
route_path=lambda *a, **kw: "/foo/bar/",
)

with pytest.raises(HTTPSeeOther) as exc:
views.add_observation(project, request)
assert exc.value.status_code == 303
assert exc.value.headers["Location"] == "/foo/bar/"

assert request.session.flash.calls == [
pretend.call("Provide a kind", queue="error")
]

def test_invalid_kind_errors(self):
project = pretend.stub(name="foo", normalized_name="foo")
request = pretend.stub(
POST={"kind": "not a valid kind"},
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
route_path=lambda *a, **kw: "/foo/bar/",
)

with pytest.raises(HTTPSeeOther) as exc:
views.add_observation(project, request)
assert exc.value.status_code == 303
assert exc.value.headers["Location"] == "/foo/bar/"

assert request.session.flash.calls == [
pretend.call("Invalid kind", queue="error")
]

def test_no_summary_errors(self):
project = pretend.stub(name="foo", normalized_name="foo")
request = pretend.stub(
POST={"kind": ObservationKind.IsSpam.value[0]},
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
route_path=lambda *a, **kw: "/foo/bar/",
)

with pytest.raises(HTTPSeeOther) as exc:
views.add_observation(project, request)
assert exc.value.status_code == 303
assert exc.value.headers["Location"] == "/foo/bar/"

assert request.session.flash.calls == [
pretend.call("Provide a summary", queue="error")
]


class TestProjectSetTotalSizeLimit:
def test_sets_total_size_limitwith_integer(self, db_request):
project = ProjectFactory.create(name="foo")
Expand Down
14 changes: 14 additions & 0 deletions warehouse/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ def includeme(config):
traverse="/{project_name}/{version}",
domain=warehouse,
)
config.add_route(
"admin.project.observations",
"/admin/projects/{project_name}/observations/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
)
config.add_route(
"admin.project.add_observation",
"/admin/projects/{project_name}/add_observation/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
)
config.add_route(
"admin.project.journals",
"/admin/projects/{project_name}/journals/",
Expand Down
10 changes: 10 additions & 0 deletions warehouse/admin/static/js/warehouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,13 @@ table.columns([".ip_address", ".hashed_ip"]).visible(false);
// add column visibility button
new $.fn.dataTable.Buttons(table, {buttons: ["copy", "csv", "colvis"]});
table.buttons().container().appendTo($(".col-md-6:eq(0)", table.table().container()));

// Observations
let obs_table = $("#observations").DataTable({
responsive: true,
lengthChange: false,
});
obs_table.column(".time").order("desc").draw();
obs_table.columns([".payload"]).visible(false);
new $.fn.dataTable.Buttons(obs_table, {buttons: ["copy", "csv", "colvis"]});
obs_table.buttons().container().appendTo($(".col-md-6:eq(0)", obs_table.table().container()));
91 changes: 91 additions & 0 deletions warehouse/admin/templates/admin/projects/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,97 @@ <h4 class="modal-title" id="exampleModalLabel">Remove role for {{ role.user.user
</div>
</div> <!-- .card #oidc-publishers -->

<div class="card card-primary card-outline collapsed-card" id="project_observations">
<div class="card-header">
<h3 class="card-title">Project Observations</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-plus"></i>
</button>
</div>
</div>

<div class="card-body">
{% if observations %}
{# TODO: Make this dataTables-enabled for sorting, filtering, etc. See Account Activity for an example. #}
<div class="table-responsive p-0">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Created</th>
<th>Kind</th>
<th>Reporter</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{% for observation in observations %}
<tr>
<td>{{ observation.created }}</td>
{# TODO: There's no exact relationship back to the ObservationKind to get the human-string. Not exactly sure how to get that yet. #}
<td>{{ observation.kind }}</td>
<td>
{# TODO: This won't work yet for non-User Observers #}
<a href="{{ request.route_path('admin.user.detail', username=observation.observer.parent.username) }}">{{ observation.observer.parent.username }}</a>
</td>
<td>{{ observation.summary }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
No observations.
{% endif %}
</div>
<div class="card-footer">
<a class="text-center" href="{{ request.route_path('admin.project.observations', project_name=project.name) }}">All observations</a>
<button type="button" class="btn btn-outline-primary float-right" data-toggle="modal" data-target="#addObservationModal">
Add Observation
</button>
<div class="modal fade" id="addObservationModal" tabindex="-1" role="dialog">
<form method="POST" action="{{ request.route_path('admin.project.add_observation', project_name=project.name) }}">
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="exampleModalLabel">Add Observation for {{ project.name }}</h4>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-auto">
<div class="form-group">
{% for observation_kind in observation_kinds %}
<div class="form-check">
<input class="form-check-input" type="radio" name="kind" id="{{ observation_kind.value[0] }}" value="{{ observation_kind.value[0] }}">
<label class="form-check-label" for="{{ observation_kind.value[0] }}" >
{{ observation_kind.value[1] }}
</label>
</div>
{% endfor %}
</div>
</div>
<div class="col">
<div class="form-group">
<label class="form-check-label" for="projectObservationText">What do we need to know?</label>
<textarea class="form-control" id="projectObservationText" name="summary" rows="3" placeholder="Enter ..."></textarea>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="submit" class="btn btn-info">Submit</button>
</div>
</div>
</div>
</form>
</div> <!-- .modal #addObservationModal -->
</div> <!-- .card-footer -->
</div> <!-- .card #project_observations -->

<div class="card card-primary card-outline collapsed-card" id="journals">
<div class="card-header">
<h3 class="card-title">Journals</h3>
Expand Down
73 changes: 73 additions & 0 deletions warehouse/admin/templates/admin/projects/observations_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-#}
{% extends "admin/base.html" %}

{% import "admin/utils/pagination.html" as pagination %}

{% block title %}Observations For {{ project.name }}{% endblock %}

{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ request.route_path('admin.project.list') }}">Projects</a></li>
<li class="breadcrumb-item"><a href="{{ request.route_path('admin.project.detail', project_name=project.normalized_name) }}">{{ project.name }}</a></li>
<li class="breadcrumb-item active">Observations</li>
{% endblock %}

{% block content %}
<div class="card">
<div class="card-body">
{% if observations %}
{# TODO: Make this dataTables-enabled for sorting, filtering, etc. See Account Activity for an example. #}
<div class="table-responsive p-0">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Created</th>
<th>Kind</th>
<th>Reporter</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{% for observation in observations %}
<tr>
<td>{{ observation.created }}</td>
{# TODO: There's no exact relationship back to the ObservationKind to get the human-string. Not exactly sure how to get that yet. #}
<td>{{ observation.kind }}</td>
<td>
{# TODO: This won't work yet for non-User Observers #}
<a href="{{ request.route_path('admin.user.detail', username=observation.observer.parent.username) }}">{{ observation.observer.parent.username }}</a>
</td>
<td>{{ observation.summary }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
No observations.
{% endif %}
</div>

<div class="card-footer row">
<div class="col-sm-5">
{{ pagination.summary(observations) }}
</div>
<div class="col-sm-7">
<div class="float-right">
{{ pagination.paginate(observations) }}
</div>
</div>
</div>
</div>
{% endblock content %}
Loading

0 comments on commit 539000f

Please sign in to comment.