From f5bf753253f51372ba62a3c920bab0740e76a60c Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Tue, 31 Oct 2023 18:02:39 -0400 Subject: [PATCH 1/6] feat: add Observers and Observations models Signed-off-by: Mike Fiedler --- tests/common/db/observations.py | 20 ++ tests/common/db/packaging.py | 13 + tests/unit/observations/__init__.py | 11 + tests/unit/observations/test_models.py | 102 ++++++++ warehouse/accounts/models.py | 3 +- .../versions/4297620f7b41_observations.py | 214 +++++++++++++++++ warehouse/observations/__init__.py | 11 + warehouse/observations/models.py | 227 ++++++++++++++++++ warehouse/packaging/models.py | 5 +- 9 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 tests/common/db/observations.py create mode 100644 tests/unit/observations/__init__.py create mode 100644 tests/unit/observations/test_models.py create mode 100644 warehouse/migrations/versions/4297620f7b41_observations.py create mode 100644 warehouse/observations/__init__.py create mode 100644 warehouse/observations/models.py diff --git a/tests/common/db/observations.py b/tests/common/db/observations.py new file mode 100644 index 000000000000..a1095af32925 --- /dev/null +++ b/tests/common/db/observations.py @@ -0,0 +1,20 @@ +# 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. + +from warehouse.observations.models import Observer + +from .base import WarehouseFactory + + +class ObserverFactory(WarehouseFactory): + class Meta: + model = Observer diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index 3ac6b295b828..f0ed676d1e78 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -18,6 +18,7 @@ import faker import packaging.utils +from warehouse.observations.models import ObservationKind from warehouse.packaging.models import ( Dependency, DependencyKind, @@ -56,6 +57,18 @@ class Meta: source = factory.SubFactory(ProjectFactory) +class ProjectObservationFactory(WarehouseFactory): + class Meta: + model = Project.Observation + + kind = factory.Faker( + "random_element", elements=[kind.value[1] for kind in ObservationKind] + ) + payload = factory.Faker("json") + # TODO: add `observer` field + summary = factory.Faker("paragraph") + + class DescriptionFactory(WarehouseFactory): class Meta: model = Description diff --git a/tests/unit/observations/__init__.py b/tests/unit/observations/__init__.py new file mode 100644 index 000000000000..164f68b09175 --- /dev/null +++ b/tests/unit/observations/__init__.py @@ -0,0 +1,11 @@ +# 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. diff --git a/tests/unit/observations/test_models.py b/tests/unit/observations/test_models.py new file mode 100644 index 000000000000..7355f142ef9e --- /dev/null +++ b/tests/unit/observations/test_models.py @@ -0,0 +1,102 @@ +# 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. + +from datetime import datetime +from uuid import UUID + +from warehouse.observations.models import ObservationKind + +from ...common.db.accounts import UserFactory +from ...common.db.observations import ObserverFactory +from ...common.db.packaging import ProjectFactory, ReleaseFactory + + +def test_observer(db_session): + observer = ObserverFactory.create() + + assert isinstance(observer.id, UUID) + assert isinstance(observer.created, datetime) + assert observer.parent is None + + +def test_user_observer_relationship(db_session): + observer = ObserverFactory.create() + user = UserFactory.create(observer=observer) + + assert user.observer == observer + assert observer.parent == user + + +def test_observer_observations_relationship(db_request): + user = UserFactory.create() + db_request.user = user + project = ProjectFactory.create() + + project.record_observation( + request=db_request, + kind=ObservationKind.SomethingElse, + summary="Project Observation", + payload={}, + observer=user, + ) + + assert len(project.observations) == 1 + observation = project.observations[0] + assert observation.observer.parent == user + assert str(observation) == "" + assert observation.kind_display == "Something Else" + + +def test_observer_created_from_user_when_observation_made(db_request): + user = UserFactory.create() + db_request.user = user + project = ProjectFactory.create() + + project.record_observation( + request=db_request, + kind=ObservationKind.SomethingElse, + summary="Project Observation", + payload={}, + observer=user, + ) + + assert len(project.observations) == 1 + observation = project.observations[0] + assert observation.observer.parent == user + assert str(observation) == "" + + +def test_user_observations_relationship(db_request): + user = UserFactory.create() + db_request.user = user + project = ProjectFactory.create() + release = ReleaseFactory.create(project=project) + + project.record_observation( + request=db_request, + kind=ObservationKind.SomethingElse, + summary="Project Observation", + payload={}, + observer=user, + ) + release.record_observation( + request=db_request, + kind=ObservationKind.SomethingElse, + summary="Release Observation", + payload={}, + observer=user, + ) + + db_request.db.flush() # so Observer is created + + assert len(user.observer.observations) == 2 + assert len(user.observations) == 2 diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 49f263745d31..95ec80b5441c 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -36,6 +36,7 @@ from warehouse import db from warehouse.events.models import HasEvents +from warehouse.observations.models import HasObserversMixin from warehouse.sitemap.models import SitemapMixin from warehouse.utils.attrs import make_repr from warehouse.utils.db.types import TZDateTime, bool_false, datetime_now @@ -68,7 +69,7 @@ class DisableReason(enum.Enum): AccountFrozen = "account frozen" -class User(SitemapMixin, HasEvents, db.Model): +class User(SitemapMixin, HasObserversMixin, HasEvents, db.Model): __tablename__ = "users" __table_args__ = ( CheckConstraint("length(username) <= 50", name="users_valid_username_length"), diff --git a/warehouse/migrations/versions/4297620f7b41_observations.py b/warehouse/migrations/versions/4297620f7b41_observations.py new file mode 100644 index 000000000000..8a8d218ca5ae --- /dev/null +++ b/warehouse/migrations/versions/4297620f7b41_observations.py @@ -0,0 +1,214 @@ +# 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. +""" +Observations + +Revision ID: 4297620f7b41 +Revises: 186f076eb60b +Create Date: 2023-10-31 21:56:51.280480 +""" + +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "4297620f7b41" +down_revision = "186f076eb60b" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. +# +# By default, migrations cannot wait more than 4s on acquiring a lock +# and each individual statement cannot take more than 5s. This helps +# prevent situations where a slow migration takes the entire site down. +# +# If you need to increase this timeout for a migration, you can do so +# by adding: +# +# op.execute("SET statement_timeout = 5000") +# op.execute("SET lock_timeout = 4000") +# +# To whatever values are reasonable for this migration as part of your +# migration. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "observer_association", + sa.Column( + "discriminator", + sa.String(), + nullable=False, + comment="The type of the parent", + ), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "observers", + sa.Column( + "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("_association_id", sa.UUID(), nullable=True), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["_association_id"], + ["observer_association.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "project_observations", + sa.Column( + "related_id", + sa.UUID(), + nullable=False, + comment="The ID of the related model", + ), + sa.Column( + "observer_id", + sa.UUID(), + nullable=False, + comment="ID of the Observer who created the Observation", + ), + sa.Column( + "created", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + comment="The time the observation was created", + ), + sa.Column( + "kind", sa.String(), nullable=False, comment="The kind of observation" + ), + sa.Column( + "summary", + sa.String(), + nullable=False, + comment="A short summary of the observation", + ), + sa.Column( + "payload", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + comment="The observation payload we received", + ), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["observer_id"], + ["observers.id"], + ), + sa.ForeignKeyConstraint( + ["related_id"], + ["projects.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_project_observations_related_id"), + "project_observations", + ["related_id"], + unique=False, + ) + op.create_table( + "release_observations", + sa.Column( + "related_id", + sa.UUID(), + nullable=False, + comment="The ID of the related model", + ), + sa.Column( + "observer_id", + sa.UUID(), + nullable=False, + comment="ID of the Observer who created the Observation", + ), + sa.Column( + "created", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + comment="The time the observation was created", + ), + sa.Column( + "kind", sa.String(), nullable=False, comment="The kind of observation" + ), + sa.Column( + "summary", + sa.String(), + nullable=False, + comment="A short summary of the observation", + ), + sa.Column( + "payload", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + comment="The observation payload we received", + ), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["observer_id"], + ["observers.id"], + ), + sa.ForeignKeyConstraint( + ["related_id"], + ["releases.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_release_observations_related_id"), + "release_observations", + ["related_id"], + unique=False, + ) + op.add_column( + "users", sa.Column("observer_association_id", sa.UUID(), nullable=True) + ) + op.create_foreign_key( + None, "users", "observer_association", ["observer_association_id"], ["id"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "users", type_="foreignkey") + op.drop_column("users", "observer_association_id") + op.drop_index( + op.f("ix_release_observations_related_id"), table_name="release_observations" + ) + op.drop_table("release_observations") + op.drop_index( + op.f("ix_project_observations_related_id"), table_name="project_observations" + ) + op.drop_table("project_observations") + op.drop_table("observers") + op.drop_table("observer_association") + # ### end Alembic commands ### diff --git a/warehouse/observations/__init__.py b/warehouse/observations/__init__.py new file mode 100644 index 000000000000..164f68b09175 --- /dev/null +++ b/warehouse/observations/__init__.py @@ -0,0 +1,11 @@ +# 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. diff --git a/warehouse/observations/models.py b/warehouse/observations/models.py new file mode 100644 index 000000000000..f5d7af49fd11 --- /dev/null +++ b/warehouse/observations/models.py @@ -0,0 +1,227 @@ +# 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. + +"""Observations and associated models.""" +from __future__ import annotations + +import enum +import typing + +from uuid import UUID + +from sqlalchemy import ForeignKey +from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.ext.declarative import AbstractConcreteBase +from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship + +from warehouse import db +from warehouse.utils.db.types import datetime_now + +if typing.TYPE_CHECKING: + from pyramid.request import Request + + +class ObserverAssociation(db.Model): + """Associate an Observer with a given parent.""" + + __tablename__ = "observer_association" + + discriminator: Mapped[str] = mapped_column(comment="The type of the parent") + observer: Mapped[Observer] = relationship( + back_populates="_association", uselist=False + ) + + __mapper_args__ = {"polymorphic_on": discriminator} + + +class Observer(db.Model): + __tablename__ = "observers" + + created: Mapped[datetime_now] + + _association_id: Mapped[UUID | None] = mapped_column( + ForeignKey(ObserverAssociation.id) + ) + _association: Mapped[ObserverAssociation] = relationship( + back_populates="observer", uselist=False + ) + + observations: Mapped[list[Observation]] = relationship() + parent: AssociationProxy = association_proxy("_association", "parent") + + +class HasObserversMixin: + """A mixin for models that can have observers.""" + + @declared_attr + def observer_association_id(cls): # noqa: N805 + return mapped_column( + PG_UUID, ForeignKey(f"{ObserverAssociation.__tablename__}.id") + ) + + @declared_attr + def observer_association(cls): # noqa: N805 + name = cls.__name__ + discriminator = name.lower() + + assoc_cls = type( + f"{name}ObserverAssociation", + (ObserverAssociation,), + dict( + __tablename__=None, + __mapper_args__={"polymorphic_identity": discriminator}, + parent=relationship( + name, + back_populates="observer_association", + uselist=False, + ), + ), + ) + + cls.observer = association_proxy( + "observer_association", + "observer", + creator=lambda o: assoc_cls(observer=o), + ) + return relationship(assoc_cls) + + @declared_attr + def observations(cls): # noqa: N805 + """Simplify `foo.observer.observations` to `foo.observations`.""" + return association_proxy( + "observer_association", + "observer.observations", + ) + + +class ObservationKind(enum.Enum): + """ + The kinds of observations we can make. Format: + + key_used_in_python = ("key_used_in_postgres", "Human Readable Name") + """ + + IsMalicious = ("is_malicious", "Is Malicious") + IsSpam = ("is_spam", "Is Spam") + SomethingElse = ("something_else", "Something Else") + + +class Observation(AbstractConcreteBase, db.Model): + """ + Observations are user-driven additions to models. + They may be used to add information to a model in a many-to-one relationship. + + The pattern followed is similar to `Event`/`HasEvents` in `warehouse.events.models`, + based on `table_per_related` from + https://docs.sqlalchemy.org/en/20/_modules/examples/generic_associations/table_per_related.html + with the addition of using `AbstractConcreteBase` to allow for a cross-table + relationship. Read more: + https://docs.sqlalchemy.org/en/20/orm/inheritance.html#abstract-concrete-base + """ + + __mapper_args__ = { + "polymorphic_identity": "observation", + } + + created: Mapped[datetime_now] = mapped_column( + comment="The time the observation was created" + ) + kind: Mapped[str] = mapped_column(comment="The kind of observation") + summary: Mapped[str] = mapped_column(comment="A short summary of the observation") + payload: Mapped[dict] = mapped_column( + JSONB, comment="The observation payload we received" + ) + + def __repr__(self): + return f"<{self.__class__.__name__} {self.kind}>" + + @property + def kind_display(self) -> str: + """ + Return the human-readable name of the observation kind. + """ + kind_map = {k.value[0]: k.value[1] for k in ObservationKind} + return kind_map[self.kind] + + +class HasObservations: + """ + A mixin for models that can have Observations. + Since Observations require a User to link to as the creator, + any code using `record_observation()` will need to pass a + `request` object that has a `user` attribute. + For Views, when using `@view_config(..., uses_session=True)`, + + Usage: + some_model.record_observation(...) + some_model.observations # a list of Observation objects + """ + + Observation: typing.ClassVar[type] + + @declared_attr + def observations(cls): # noqa: N805 + cls.Observation = type( + f"{cls.__name__}Observation", + (Observation, db.Model), + dict( + __tablename__=f"{cls.__name__.lower()}_observations", + __mapper_args__={ + "polymorphic_identity": cls.__name__.lower(), + "concrete": True, + }, + related_id=mapped_column( + PG_UUID, + ForeignKey(f"{cls.__tablename__}.id"), + comment="The ID of the related model", + nullable=False, + index=True, + ), + related=relationship(cls, back_populates="observations"), + observer_id=mapped_column( + PG_UUID, + ForeignKey("observers.id"), + comment="ID of the Observer who created the Observation", + nullable=False, + ), + observer=relationship(Observer), + ), + ) + return relationship(cls.Observation) + + def record_observation( + self, + *, + request: Request, + kind: ObservationKind, + observer, # TODO: Rename and add type, "observer.observer" is confusing + summary: str, + payload: dict, + ): + """ + Record an observation on the related model. + """ + if observer.observer is None: + observer.observer = Observer() + + observation = self.Observation( + kind=kind.value[0], + observer=observer.observer, + payload=payload, + related=self, + summary=summary, + ) + + request.db.add(observation) + + return observation diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 5f9fe7b04235..2bb9ad491ae7 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -54,6 +54,7 @@ from warehouse.classifiers.models import Classifier from warehouse.events.models import HasEvents from warehouse.integrations.vulnerabilities.models import VulnerabilityRecord +from warehouse.observations.models import HasObservations from warehouse.organizations.models import ( Organization, OrganizationProject, @@ -158,7 +159,7 @@ def two_factor_required(self): return self.owners_require_2fa | self.pypi_mandates_2fa -class Project(SitemapMixin, TwoFactorRequireable, HasEvents, db.Model): +class Project(SitemapMixin, TwoFactorRequireable, HasEvents, HasObservations, db.Model): __tablename__ = "projects" __repr__ = make_repr("name") @@ -427,7 +428,7 @@ class ReleaseURL(db.Model): url: Mapped[str] -class Release(db.Model): +class Release(HasObservations, db.Model): __tablename__ = "releases" @declared_attr From d9abbd032198fc2fcb6a3eca2ee6fb1343a4873c Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Tue, 31 Oct 2023 18:04:26 -0400 Subject: [PATCH 2/6] feat(admin): Add Project.Observation behaviors Signed-off-by: Mike Fiedler --- tests/unit/admin/test_routes.py | 14 +++ tests/unit/admin/views/test_projects.py | 116 ++++++++++++++++++ warehouse/admin/routes.py | 14 +++ warehouse/admin/static/js/warehouse.js | 10 ++ .../templates/admin/projects/detail.html | 91 ++++++++++++++ .../admin/projects/observations_list.html | 73 +++++++++++ .../admin/templates/admin/users/detail.html | 33 +++++ warehouse/admin/views/projects.py | 99 +++++++++++++++ 8 files changed, 450 insertions(+) create mode 100644 warehouse/admin/templates/admin/projects/observations_list.html diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index 0d2e19854d20..e85918be25bc 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -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/", diff --git a/tests/unit/admin/views/test_projects.py b/tests/unit/admin/views/test_projects.py index 989274a3e2eb..16afd261b486 100644 --- a/tests/unit/admin/views/test_projects.py +++ b/tests/unit/admin/views/test_projects.py @@ -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, ) @@ -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): @@ -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") diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index b9fd8fb14431..72b5592e981f 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -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/", diff --git a/warehouse/admin/static/js/warehouse.js b/warehouse/admin/static/js/warehouse.js index 2470f3f9cf34..1191e22d234a 100644 --- a/warehouse/admin/static/js/warehouse.js +++ b/warehouse/admin/static/js/warehouse.js @@ -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())); diff --git a/warehouse/admin/templates/admin/projects/detail.html b/warehouse/admin/templates/admin/projects/detail.html index aa3a8041e825..849ebfaa2e9e 100644 --- a/warehouse/admin/templates/admin/projects/detail.html +++ b/warehouse/admin/templates/admin/projects/detail.html @@ -267,6 +267,97 @@