Skip to content

Commit c20a241

Browse files
authored
feat: add Observers and Observations models (#14834)
1 parent d5ee54d commit c20a241

File tree

18 files changed

+1318
-4
lines changed

18 files changed

+1318
-4
lines changed

tests/common/db/observations.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
from warehouse.observations.models import Observer
14+
15+
from .base import WarehouseFactory
16+
17+
18+
class ObserverFactory(WarehouseFactory):
19+
class Meta:
20+
model = Observer

tests/common/db/packaging.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import faker
1919
import packaging.utils
2020

21+
from warehouse.observations.models import ObservationKind
2122
from warehouse.packaging.models import (
2223
Dependency,
2324
DependencyKind,
@@ -56,6 +57,18 @@ class Meta:
5657
source = factory.SubFactory(ProjectFactory)
5758

5859

60+
class ProjectObservationFactory(WarehouseFactory):
61+
class Meta:
62+
model = Project.Observation
63+
64+
kind = factory.Faker(
65+
"random_element", elements=[kind.value[1] for kind in ObservationKind]
66+
)
67+
payload = factory.Faker("json")
68+
# TODO: add `observer` field
69+
summary = factory.Faker("paragraph")
70+
71+
5972
class DescriptionFactory(WarehouseFactory):
6073
class Meta:
6174
model = Description

tests/unit/admin/test_routes.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,34 @@ def test_includeme():
141141
traverse="/{project_name}/{version}",
142142
domain=warehouse,
143143
),
144+
pretend.call(
145+
"admin.project.observations",
146+
"/admin/projects/{project_name}/observations/",
147+
factory="warehouse.packaging.models:ProjectFactory",
148+
traverse="/{project_name}",
149+
domain=warehouse,
150+
),
151+
pretend.call(
152+
"admin.project.add_project_observation",
153+
"/admin/projects/{project_name}/add_project_observation/",
154+
factory="warehouse.packaging.models:ProjectFactory",
155+
traverse="/{project_name}",
156+
domain=warehouse,
157+
),
158+
pretend.call(
159+
"admin.project.release.observations",
160+
"/admin/projects/{project_name}/release/{version}/observations/",
161+
factory="warehouse.packaging.models:ProjectFactory",
162+
traverse="/{project_name}/{version}",
163+
domain=warehouse,
164+
),
165+
pretend.call(
166+
"admin.project.release.add_release_observation",
167+
"/admin/projects/{project_name}/release/{version}/add_release_observation/",
168+
factory="warehouse.packaging.models:ProjectFactory",
169+
traverse="/{project_name}/{version}",
170+
domain=warehouse,
171+
),
144172
pretend.call(
145173
"admin.project.journals",
146174
"/admin/projects/{project_name}/journals/",

tests/unit/admin/views/test_projects.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@
2121

2222
from tests.common.db.oidc import GitHubPublisherFactory
2323
from warehouse.admin.views import projects as views
24+
from warehouse.observations.models import ObservationKind
2425
from warehouse.packaging.models import Project, Role
2526
from warehouse.packaging.tasks import update_release_description
2627
from warehouse.search.tasks import reindex_project
2728

2829
from ....common.db.accounts import UserFactory
30+
from ....common.db.observations import ObserverFactory
2931
from ....common.db.packaging import (
3032
JournalEntryFactory,
3133
ProjectFactory,
34+
ProjectObservationFactory,
3235
ReleaseFactory,
3336
RoleFactory,
3437
)
@@ -101,6 +104,8 @@ def test_gets_project(self, db_request):
101104
"MAX_PROJECT_SIZE": views.MAX_PROJECT_SIZE,
102105
"ONE_GB": views.ONE_GB,
103106
"UPLOAD_LIMIT_CAP": views.UPLOAD_LIMIT_CAP,
107+
"observation_kinds": ObservationKind,
108+
"observations": [],
104109
}
105110

106111
def test_non_normalized_name(self, db_request):
@@ -128,6 +133,8 @@ def test_gets_release(self, db_request):
128133
assert views.release_detail(release, db_request) == {
129134
"release": release,
130135
"journals": journals,
136+
"observation_kinds": ObservationKind,
137+
"observations": [],
131138
}
132139

133140
def test_release_render(self, db_request):
@@ -157,6 +164,80 @@ def test_release_render(self, db_request):
157164
]
158165

159166

167+
class TestReleaseAddObservation:
168+
def test_add_observation(self, db_request):
169+
release = ReleaseFactory.create()
170+
user = UserFactory.create()
171+
db_request.route_path = pretend.call_recorder(
172+
lambda *a, **kw: "/admin/projects/"
173+
)
174+
db_request.matchdict["project_name"] = release.project.normalized_name
175+
db_request.POST["kind"] = ObservationKind.IsSpam.value[0]
176+
db_request.POST["summary"] = "This is a summary"
177+
db_request.user = user
178+
179+
views.add_release_observation(release, db_request)
180+
181+
assert len(release.observations) == 1
182+
183+
def test_no_kind_errors(self):
184+
release = pretend.stub(
185+
project=pretend.stub(name="foo", normalized_name="foo"), version="1.0"
186+
)
187+
request = pretend.stub(
188+
POST={},
189+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
190+
route_path=lambda *a, **kw: "/foo/bar/",
191+
)
192+
193+
with pytest.raises(HTTPSeeOther) as exc:
194+
views.add_release_observation(release, request)
195+
assert exc.value.status_code == 303
196+
assert exc.value.headers["Location"] == "/foo/bar/"
197+
198+
assert request.session.flash.calls == [
199+
pretend.call("Provide a kind", queue="error")
200+
]
201+
202+
def test_invalid_kind_errors(self):
203+
release = pretend.stub(
204+
project=pretend.stub(name="foo", normalized_name="foo"), version="1.0"
205+
)
206+
request = pretend.stub(
207+
POST={"kind": "not a valid kind"},
208+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
209+
route_path=lambda *a, **kw: "/foo/bar/",
210+
)
211+
212+
with pytest.raises(HTTPSeeOther) as exc:
213+
views.add_release_observation(release, request)
214+
assert exc.value.status_code == 303
215+
assert exc.value.headers["Location"] == "/foo/bar/"
216+
217+
assert request.session.flash.calls == [
218+
pretend.call("Invalid kind", queue="error")
219+
]
220+
221+
def test_no_summary_errors(self):
222+
release = pretend.stub(
223+
project=pretend.stub(name="foo", normalized_name="foo"), version="1.0"
224+
)
225+
request = pretend.stub(
226+
POST={"kind": ObservationKind.IsSpam.value[0]},
227+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
228+
route_path=lambda *a, **kw: "/foo/bar/",
229+
)
230+
231+
with pytest.raises(HTTPSeeOther) as exc:
232+
views.add_release_observation(release, request)
233+
assert exc.value.status_code == 303
234+
assert exc.value.headers["Location"] == "/foo/bar/"
235+
236+
assert request.session.flash.calls == [
237+
pretend.call("Provide a summary", queue="error")
238+
]
239+
240+
160241
class TestProjectReleasesList:
161242
def test_no_query(self, db_request):
162243
project = ProjectFactory.create()
@@ -347,6 +428,117 @@ def test_non_normalized_name(self, db_request):
347428
views.journals_list(project, db_request)
348429

349430

431+
class TestProjectObservationsList:
432+
def test_with_page(self, db_request):
433+
observer = ObserverFactory.create()
434+
UserFactory.create(observer=observer)
435+
project = ProjectFactory.create()
436+
observations = ProjectObservationFactory.create_batch(
437+
size=30, related=project, observer=observer
438+
)
439+
440+
db_request.matchdict["project_name"] = project.normalized_name
441+
db_request.GET["page"] = "2"
442+
result = views.project_observations_list(project, db_request)
443+
444+
assert result == {
445+
"observations": observations[25:],
446+
"project": project,
447+
}
448+
449+
def test_with_invalid_page(self, db_request):
450+
project = ProjectFactory.create()
451+
db_request.matchdict["project_name"] = project.normalized_name
452+
db_request.GET["page"] = "not an integer"
453+
454+
with pytest.raises(HTTPBadRequest):
455+
views.project_observations_list(project, db_request)
456+
457+
458+
class TestProjectAddObservation:
459+
def test_add_observation(self, db_request):
460+
project = ProjectFactory.create()
461+
observer = ObserverFactory.create()
462+
user = UserFactory.create(observer=observer)
463+
db_request.route_path = pretend.call_recorder(
464+
lambda *a, **kw: "/admin/projects/"
465+
)
466+
db_request.matchdict["project_name"] = project.normalized_name
467+
db_request.POST["kind"] = ObservationKind.IsSpam.value[0]
468+
db_request.POST["summary"] = "This is a summary"
469+
db_request.user = user
470+
471+
views.add_project_observation(project, db_request)
472+
473+
assert len(project.observations) == 1
474+
475+
def test_no_user_observer(self, db_request):
476+
project = ProjectFactory.create()
477+
user = UserFactory.create()
478+
db_request.route_path = pretend.call_recorder(
479+
lambda *a, **kw: "/admin/projects/"
480+
)
481+
db_request.matchdict["project_name"] = project.normalized_name
482+
db_request.POST["kind"] = ObservationKind.IsSpam.value[0]
483+
db_request.POST["summary"] = "This is a summary"
484+
db_request.user = user
485+
486+
views.add_project_observation(project, db_request)
487+
488+
assert len(project.observations) == 1
489+
490+
def test_no_kind_errors(self):
491+
project = pretend.stub(name="foo", normalized_name="foo")
492+
request = pretend.stub(
493+
POST={},
494+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
495+
route_path=lambda *a, **kw: "/foo/bar/",
496+
)
497+
498+
with pytest.raises(HTTPSeeOther) as exc:
499+
views.add_project_observation(project, request)
500+
assert exc.value.status_code == 303
501+
assert exc.value.headers["Location"] == "/foo/bar/"
502+
503+
assert request.session.flash.calls == [
504+
pretend.call("Provide a kind", queue="error")
505+
]
506+
507+
def test_invalid_kind_errors(self):
508+
project = pretend.stub(name="foo", normalized_name="foo")
509+
request = pretend.stub(
510+
POST={"kind": "not a valid kind"},
511+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
512+
route_path=lambda *a, **kw: "/foo/bar/",
513+
)
514+
515+
with pytest.raises(HTTPSeeOther) as exc:
516+
views.add_project_observation(project, request)
517+
assert exc.value.status_code == 303
518+
assert exc.value.headers["Location"] == "/foo/bar/"
519+
520+
assert request.session.flash.calls == [
521+
pretend.call("Invalid kind", queue="error")
522+
]
523+
524+
def test_no_summary_errors(self):
525+
project = pretend.stub(name="foo", normalized_name="foo")
526+
request = pretend.stub(
527+
POST={"kind": ObservationKind.IsSpam.value[0]},
528+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
529+
route_path=lambda *a, **kw: "/foo/bar/",
530+
)
531+
532+
with pytest.raises(HTTPSeeOther) as exc:
533+
views.add_project_observation(project, request)
534+
assert exc.value.status_code == 303
535+
assert exc.value.headers["Location"] == "/foo/bar/"
536+
537+
assert request.session.flash.calls == [
538+
pretend.call("Provide a summary", queue="error")
539+
]
540+
541+
350542
class TestProjectSetTotalSizeLimit:
351543
def test_sets_total_size_limitwith_integer(self, db_request):
352544
project = ProjectFactory.create(name="foo")

tests/unit/observations/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.

0 commit comments

Comments
 (0)