diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index 2a12379da170..f948d6cbe899 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -20,6 +20,7 @@ from warehouse.observations.models import ObservationKind from warehouse.packaging.models import ( + AlternateRepository, Dependency, DependencyKind, Description, @@ -200,3 +201,13 @@ class Meta: ) name = factory.Faker("pystr", max_chars=12) prohibited_by = factory.SubFactory(UserFactory) + + +class AlternateRepositoryFactory(WarehouseFactory): + class Meta: + model = AlternateRepository + + name = factory.Faker("word") + url = factory.Faker("uri") + description = factory.Faker("text") + project = factory.SubFactory(ProjectFactory) diff --git a/tests/unit/api/test_simple.py b/tests/unit/api/test_simple.py index 9937038dd145..ec2367dded73 100644 --- a/tests/unit/api/test_simple.py +++ b/tests/unit/api/test_simple.py @@ -18,10 +18,11 @@ from pyramid.testing import DummyRequest from warehouse.api import simple -from warehouse.packaging.utils import API_VERSION +from warehouse.packaging.utils import API_VERSION, _valid_simple_detail_context from ...common.db.accounts import UserFactory from ...common.db.packaging import ( + AlternateRepositoryFactory, FileFactory, JournalEntryFactory, ProjectFactory, @@ -48,29 +49,30 @@ def test_defaults_text_html(self, header): default to text/html. """ request = DummyRequest(accept=header) - assert simple._select_content_type(request) == "text/html" + assert simple._select_content_type(request) == simple.MIME_TEXT_HTML @pytest.mark.parametrize( ("header", "expected"), [ - ("text/html", "text/html"), + (simple.MIME_TEXT_HTML, simple.MIME_TEXT_HTML), ( - "application/vnd.pypi.simple.v1+html", - "application/vnd.pypi.simple.v1+html", + simple.MIME_PYPI_SIMPLE_V1_HTML, + simple.MIME_PYPI_SIMPLE_V1_HTML, ), ( - "application/vnd.pypi.simple.v1+json", - "application/vnd.pypi.simple.v1+json", + simple.MIME_PYPI_SIMPLE_V1_JSON, + simple.MIME_PYPI_SIMPLE_V1_JSON, ), ( - "text/html, application/vnd.pypi.simple.v1+html, " - "application/vnd.pypi.simple.v1+json", - "text/html", + f"{simple.MIME_TEXT_HTML}, {simple.MIME_PYPI_SIMPLE_V1_HTML}, " + f"{simple.MIME_PYPI_SIMPLE_V1_JSON}", + simple.MIME_TEXT_HTML, ), ( - "text/html;q=0.01, application/vnd.pypi.simple.v1+html;q=0.2, " - "application/vnd.pypi.simple.v1+json", - "application/vnd.pypi.simple.v1+json", + f"{simple.MIME_TEXT_HTML};q=0.01, " + f"{simple.MIME_PYPI_SIMPLE_V1_HTML};q=0.2, " + f"{simple.MIME_PYPI_SIMPLE_V1_JSON}", + simple.MIME_PYPI_SIMPLE_V1_JSON, ), ], ) @@ -80,9 +82,9 @@ def test_selects(self, header, expected): CONTENT_TYPE_PARAMS = [ - ("text/html", None), - ("application/vnd.pypi.simple.v1+html", None), - ("application/vnd.pypi.simple.v1+json", "json"), + (simple.MIME_TEXT_HTML, None), + (simple.MIME_PYPI_SIMPLE_V1_HTML, None), + (simple.MIME_PYPI_SIMPLE_V1_JSON, "json"), ] @@ -211,12 +213,15 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override): user = UserFactory.create() JournalEntryFactory.create(submitted_by=user) - assert simple.simple_detail(project, db_request) == { + context = { "meta": {"_last-serial": 0, "api-version": API_VERSION}, "name": project.normalized_name, "files": [], "versions": [], + "alternate-locations": [], } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context assert db_request.response.headers["X-PyPI-Last-Serial"] == "0" assert db_request.response.content_type == content_type @@ -235,13 +240,20 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override) db_request.matchdict["name"] = project.normalized_name user = UserFactory.create() je = JournalEntryFactory.create(name=project.name, submitted_by=user) + als = [ + AlternateRepositoryFactory.create(project=project), + AlternateRepositoryFactory.create(project=project), + ] - assert simple.simple_detail(project, db_request) == { + context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, "files": [], "versions": [], + "alternate-locations": sorted(al.url for al in als), } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id) assert db_request.response.content_type == content_type @@ -271,7 +283,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override) user = UserFactory.create() JournalEntryFactory.create(submitted_by=user) - assert simple.simple_detail(project, db_request) == { + context = { "meta": {"_last-serial": 0, "api-version": API_VERSION}, "name": project.normalized_name, "versions": release_versions, @@ -289,7 +301,10 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override) } for f in files ], + "alternate-locations": [], } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context assert db_request.response.headers["X-PyPI-Last-Serial"] == "0" assert db_request.response.content_type == content_type @@ -319,7 +334,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid user = UserFactory.create() je = JournalEntryFactory.create(name=project.name, submitted_by=user) - assert simple.simple_detail(project, db_request) == { + context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, "versions": release_versions, @@ -337,7 +352,10 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid } for f in files ], + "alternate-locations": [], } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id) assert db_request.response.content_type == content_type @@ -404,7 +422,7 @@ def test_with_files_with_version_multi_digit( user = UserFactory.create() je = JournalEntryFactory.create(name=project.name, submitted_by=user) - assert simple.simple_detail(project, db_request) == { + context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, "versions": release_versions, @@ -430,7 +448,10 @@ def test_with_files_with_version_multi_digit( } for f in files ], + "alternate-locations": [], } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id) assert db_request.response.content_type == content_type @@ -439,6 +460,15 @@ def test_with_files_with_version_multi_digit( if renderer_override is not None: assert db_request.override_renderer == renderer_override + +def _update_context(context, content_type, renderer_override): + if renderer_override != "json" or content_type in [ + simple.MIME_TEXT_HTML, + simple.MIME_PYPI_SIMPLE_V1_HTML, + ]: + return _valid_simple_detail_context(context) + return context + def test_with_files_quarantined_omitted_from_index(self, db_request): db_request.accept = "text/html" project = ProjectFactory.create(lifecycle_status="quarantine-enter") diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 09a470a58e5e..fb16a5a325ec 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -82,6 +82,7 @@ TeamRoleFactory, ) from ...common.db.packaging import ( + AlternateRepositoryFactory, FileEventFactory, FileFactory, JournalEntryFactory, @@ -2606,6 +2607,7 @@ def test_manage_project_settings(self, enabled, monkeypatch): view = views.ManageProjectSettingsViews(project, request) form = pretend.stub() view.transfer_organization_project_form_class = lambda *a, **kw: form + view.add_alternate_repository_form_class = lambda *a, **kw: form user_organizations = pretend.call_recorder( lambda *a, **kw: { @@ -2621,6 +2623,7 @@ def test_manage_project_settings(self, enabled, monkeypatch): "MAX_FILESIZE": MAX_FILESIZE, "MAX_PROJECT_SIZE": MAX_PROJECT_SIZE, "transfer_organization_project_form": form, + "add_alternate_repository_form_class": form, } def test_manage_project_settings_in_organization_managed(self, monkeypatch): @@ -2633,6 +2636,7 @@ def test_manage_project_settings_in_organization_managed(self, monkeypatch): view.transfer_organization_project_form_class = pretend.call_recorder( lambda *a, **kw: form ) + view.add_alternate_repository_form_class = lambda *a, **kw: form user_organizations = pretend.call_recorder( lambda *a, **kw: { @@ -2648,6 +2652,7 @@ def test_manage_project_settings_in_organization_managed(self, monkeypatch): "MAX_FILESIZE": MAX_FILESIZE, "MAX_PROJECT_SIZE": MAX_PROJECT_SIZE, "transfer_organization_project_form": form, + "add_alternate_repository_form_class": form, } assert view.transfer_organization_project_form_class.calls == [ pretend.call(organization_choices={"owned-org"}) @@ -2663,6 +2668,7 @@ def test_manage_project_settings_in_organization_owned(self, monkeypatch): view.transfer_organization_project_form_class = pretend.call_recorder( lambda *a, **kw: form ) + view.add_alternate_repository_form_class = lambda *a, **kw: form user_organizations = pretend.call_recorder( lambda *a, **kw: { @@ -2678,11 +2684,266 @@ def test_manage_project_settings_in_organization_owned(self, monkeypatch): "MAX_FILESIZE": MAX_FILESIZE, "MAX_PROJECT_SIZE": MAX_PROJECT_SIZE, "transfer_organization_project_form": form, + "add_alternate_repository_form_class": form, } assert view.transfer_organization_project_form_class.calls == [ pretend.call(organization_choices={"managed-org"}) ] + def test_add_alternate_repository(self, monkeypatch, db_request): + project = ProjectFactory.create(name="foo") + + db_request.POST = MultiDict( + { + "display_name": "foo alt repo", + "link_url": "https://example.org", + "description": "foo alt repo descr", + "alternate_repository_location": "add", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + add_alternate_repository_form_class = pretend.call_recorder( + views.AddAlternateRepositoryForm + ) + monkeypatch.setattr( + views, + "AddAlternateRepositoryForm", + add_alternate_repository_form_class, + ) + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.add_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call("Added alternate repository 'foo alt repo'", queue="success") + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + assert add_alternate_repository_form_class.calls == [ + pretend.call(db_request.POST) + ] + + def test_add_alternate_repository_invalid(self, monkeypatch, db_request): + project = ProjectFactory.create(name="foo") + + db_request.POST = MultiDict( + { + "display_name": "foo alt repo", + "link_url": "invalid link", + "description": "foo alt repo descr", + "alternate_repository_location": "add", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + add_alternate_repository_form_class = pretend.call_recorder( + views.AddAlternateRepositoryForm + ) + monkeypatch.setattr( + views, + "AddAlternateRepositoryForm", + add_alternate_repository_form_class, + ) + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.add_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call("Invalid alternate repository location details", queue="error") + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + assert add_alternate_repository_form_class.calls == [ + pretend.call(db_request.POST) + ] + + def test_delete_alternate_repository(self, db_request): + project = ProjectFactory.create(name="foo") + alt_repo = AlternateRepositoryFactory.create(project=project) + + db_request.POST = MultiDict( + { + "alternate_repository_id": str(alt_repo.id), + "confirm_alternate_repository_name": alt_repo.name, + "alternate_repository_location": "delete", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.delete_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call( + f"Deleted alternate repository '{alt_repo.name}'", queue="success" + ) + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + + @pytest.mark.parametrize("alt_repo_id", [None, "", "blah"]) + def test_delete_alternate_repository_invalid_id(self, db_request, alt_repo_id): + project = ProjectFactory.create(name="foo") + alt_repo = AlternateRepositoryFactory.create(project=project) + + db_request.POST = MultiDict( + { + "alternate_repository_id": alt_repo_id, + "confirm_alternate_repository_name": alt_repo.name, + "alternate_repository_location": "delete", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.delete_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call("Invalid alternate repository id", queue="error") + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + + def test_delete_alternate_repository_wrong_id(self, db_request): + project = ProjectFactory.create(name="foo") + alt_repo = AlternateRepositoryFactory.create(project=project) + + db_request.POST = MultiDict( + { + "alternate_repository_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "confirm_alternate_repository_name": alt_repo.name, + "alternate_repository_location": "delete", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.delete_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call("Invalid alternate repository for project", queue="error") + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + + def test_delete_alternate_repository_no_confirm(self, db_request): + project = ProjectFactory.create(name="foo") + alt_repo = AlternateRepositoryFactory.create(project=project) + + db_request.POST = MultiDict( + { + "alternate_repository_id": str(alt_repo.id), + "alternate_repository_location": "delete", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.delete_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call("Confirm the request", queue="error") + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + + def test_delete_alternate_repository_wrong_confirm(self, db_request): + project = ProjectFactory.create(name="foo") + alt_repo = AlternateRepositoryFactory.create(project=project) + + db_request.POST = MultiDict( + { + "alternate_repository_id": str(alt_repo.id), + "confirm_alternate_repository_name": f"invalid-confirm-{alt_repo.name}", + "alternate_repository_location": "delete", + } + ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = UserFactory.create() + + RoleFactory.create(project=project, user=db_request.user, role_name="Owner") + + settings_views = views.ManageProjectSettingsViews(project, db_request) + result = settings_views.delete_project_alternate_repository() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + assert db_request.session.flash.calls == [ + pretend.call( + f"Could not delete alternate repository - " + f"invalid-confirm-{alt_repo.name} is not the same as {alt_repo.name}", + queue="error", + ) + ] + assert db_request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + def test_remove_organization_project_no_confirm(self): user = pretend.stub() project = pretend.stub( diff --git a/tests/unit/packaging/test_init.py b/tests/unit/packaging/test_init.py index 823bfce89647..ad0efbee2ac9 100644 --- a/tests/unit/packaging/test_init.py +++ b/tests/unit/packaging/test_init.py @@ -25,7 +25,7 @@ IProjectService, ISimpleStorage, ) -from warehouse.packaging.models import File, Project, Release, Role +from warehouse.packaging.models import AlternateRepository, File, Project, Release, Role from warehouse.packaging.services import project_service_factory from warehouse.packaging.tasks import ( # sync_bigquery_release_files, check_file_cache_tasks_outstanding, @@ -159,6 +159,13 @@ def key_factory(keystring, iterate_on=None, if_attr_exists=None): key_factory("project/{itr.normalized_name}", iterate_on="projects"), ], ), + pretend.call( + AlternateRepository, + cache_keys=["project/{obj.project.normalized_name}"], + purge_keys=[ + key_factory("project/{obj.project.normalized_name}"), + ], + ), ] if with_bq_sync: diff --git a/tests/unit/packaging/test_utils.py b/tests/unit/packaging/test_utils.py index afa7bd2056fe..e256ca0300e1 100644 --- a/tests/unit/packaging/test_utils.py +++ b/tests/unit/packaging/test_utils.py @@ -16,7 +16,11 @@ import pretend from warehouse.packaging.interfaces import ISimpleStorage -from warehouse.packaging.utils import _simple_detail, render_simple_detail +from warehouse.packaging.utils import ( + _simple_detail, + _valid_simple_detail_context, + render_simple_detail, +) from ...common.db.packaging import FileFactory, ProjectFactory, ReleaseFactory @@ -50,9 +54,9 @@ def test_render_simple_detail(db_request, monkeypatch, jinja): db_request.route_url = lambda *a, **kw: "the-url" template = jinja.get_template("templates/api/simple/detail.html") - expected_content = template.render( - **_simple_detail(project, db_request), request=db_request - ).encode("utf-8") + context = _simple_detail(project, db_request) + context = _valid_simple_detail_context(context) + expected_content = template.render(**context, request=db_request).encode("utf-8") content_hash, path = render_simple_detail(project, db_request) @@ -107,9 +111,9 @@ def __exit__(self, type, value, traceback): monkeypatch.setattr(tempfile, "NamedTemporaryFile", FakeNamedTemporaryFile) template = jinja.get_template("templates/api/simple/detail.html") - expected_content = template.render( - **_simple_detail(project, db_request), request=db_request - ).encode("utf-8") + context = _simple_detail(project, db_request) + context = _valid_simple_detail_context(context) + expected_content = template.render(**context, request=db_request).encode("utf-8") content_hash, path = render_simple_detail(project, db_request, store=True) diff --git a/warehouse/api/simple.py b/warehouse/api/simple.py index faa7bf3b6dc9..a7882af67c4b 100644 --- a/warehouse/api/simple.py +++ b/warehouse/api/simple.py @@ -19,9 +19,17 @@ from warehouse.cache.http import add_vary, cache_control from warehouse.cache.origin import origin_cache from warehouse.packaging.models import JournalEntry, Project -from warehouse.packaging.utils import _simple_detail, _simple_index +from warehouse.packaging.utils import ( + _simple_detail, + _simple_index, + _valid_simple_detail_context, +) from warehouse.utils.cors import _CORS_HEADERS +MIME_TEXT_HTML = "text/html" +MIME_PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html" +MIME_PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json" + def _select_content_type(request: Request) -> str: # The way this works, is this will return a list of @@ -36,16 +44,16 @@ def _select_content_type(request: Request) -> str: # match, it will be an empty list. offers = request.accept.acceptable_offers( [ - "text/html", - "application/vnd.pypi.simple.v1+html", - "application/vnd.pypi.simple.v1+json", + MIME_TEXT_HTML, + MIME_PYPI_SIMPLE_V1_HTML, + MIME_PYPI_SIMPLE_V1_JSON, ] ) # Default case, we want to return whatevr we want to return # by default when there is no Accept header. if not offers: - return "text/html" + return MIME_TEXT_HTML # We've selected a list of acceptable offers, so we'll take # the first one as our return type. else: @@ -69,7 +77,7 @@ def simple_index(request): # Determine what our content-type should be, and setup our request # to return the correct content types. request.response.content_type = _select_content_type(request) - if request.response.content_type == "application/vnd.pypi.simple.v1+json": + if request.response.content_type == MIME_PYPI_SIMPLE_V1_JSON: request.override_renderer = "json" # Apply CORS headers. @@ -109,7 +117,7 @@ def simple_detail(project, request): # Determine what our content-type should be, and setup our request # to return the correct content types. request.response.content_type = _select_content_type(request) - if request.response.content_type == "application/vnd.pypi.simple.v1+json": + if request.response.content_type == MIME_PYPI_SIMPLE_V1_JSON: request.override_renderer = "json" # Apply CORS headers. @@ -118,4 +126,10 @@ def simple_detail(project, request): # Get the latest serial number for this project. request.response.headers["X-PyPI-Last-Serial"] = str(project.last_serial) - return _simple_detail(project, request) + context = _simple_detail(project, request) + + # Modify the Jinja context to use valid variable name + if request.response.content_type != MIME_PYPI_SIMPLE_V1_JSON: + context = _valid_simple_detail_context(context) + + return context diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py index e26c53f039a8..61e3161c8e37 100644 --- a/warehouse/events/tags.py +++ b/warehouse/events/tags.py @@ -102,6 +102,8 @@ class Account(EventTagEnum): TwoFactorMethodAdded = "account:two_factor:method_added" TwoFactorMethodRemoved = "account:two_factor:method_removed" EmailSent = "account:email:sent" + AlternateRepositoryAdd = "account:alternate_repository:add" + AlternateRepositoryDelete = "account:alternate_repository:delete" # The following tags are no longer used when recording events. # ReauthenticateFailure = "account:reauthenticate:failure" # RoleAccepted = "account:role:accepted" @@ -138,6 +140,8 @@ class Project(EventTagEnum): TeamProjectRoleAdd = "project:team_project_role:add" TeamProjectRoleChange = "project:team_project_role:change" TeamProjectRoleRemove = "project:team_project_role:remove" + AlternateRepositoryAdd = "project:alternate_repository:add" + AlternateRepositoryDelete = "project:alternate_repository:delete" # The following tags are no longer used when recording events. # RoleAccepted = "project:role:accepted" # RoleDelete = "project:role:delete" diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 5a445e618f7b..29f7ffbae55e 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -91,6 +91,7 @@ msgid "" msgstr "" #: warehouse/accounts/forms.py:388 warehouse/manage/forms.py:139 +#: warehouse/manage/forms.py:728 msgid "The name is too long. Choose a name with 100 characters or less." msgstr "" @@ -151,7 +152,7 @@ msgstr "" msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:870 +#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:873 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" @@ -285,7 +286,7 @@ msgid "You are now ${role} of the '${project_name}' project." msgstr "" #: warehouse/accounts/views.py:1548 warehouse/accounts/views.py:1791 -#: warehouse/manage/views/__init__.py:1249 +#: warehouse/manage/views/__init__.py:1425 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." @@ -305,19 +306,19 @@ msgstr "" msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1614 warehouse/manage/views/__init__.py:1304 -#: warehouse/manage/views/__init__.py:1417 -#: warehouse/manage/views/__init__.py:1529 -#: warehouse/manage/views/__init__.py:1639 +#: warehouse/accounts/views.py:1614 warehouse/manage/views/__init__.py:1480 +#: warehouse/manage/views/__init__.py:1593 +#: warehouse/manage/views/__init__.py:1705 +#: warehouse/manage/views/__init__.py:1815 msgid "" "There have been too many attempted trusted publisher registrations. Try " "again later." msgstr "" -#: warehouse/accounts/views.py:1625 warehouse/manage/views/__init__.py:1318 -#: warehouse/manage/views/__init__.py:1431 -#: warehouse/manage/views/__init__.py:1543 -#: warehouse/manage/views/__init__.py:1653 +#: warehouse/accounts/views.py:1625 warehouse/manage/views/__init__.py:1494 +#: warehouse/manage/views/__init__.py:1607 +#: warehouse/manage/views/__init__.py:1719 +#: warehouse/manage/views/__init__.py:1829 msgid "The trusted publisher could not be registered" msgstr "" @@ -427,130 +428,175 @@ msgstr "" msgid "This team name has already been used. Choose a different team name." msgstr "" -#: warehouse/manage/views/__init__.py:282 +#: warehouse/manage/forms.py:724 +msgid "Specify your alternate repository name" +msgstr "" + +#: warehouse/manage/forms.py:738 +msgid "Specify your alternate repository URL" +msgstr "" + +#: warehouse/manage/forms.py:742 +msgid "The URL is too long. Choose a URL with 400 characters or less." +msgstr "" + +#: warehouse/manage/forms.py:756 +msgid "" +"The description is too long. Choose a description with 400 characters or " +"less." +msgstr "" + +#: warehouse/manage/views/__init__.py:285 msgid "Account details updated" msgstr "" -#: warehouse/manage/views/__init__.py:312 +#: warehouse/manage/views/__init__.py:315 msgid "Email ${email_address} added - check your email for a verification link" msgstr "" -#: warehouse/manage/views/__init__.py:818 +#: warehouse/manage/views/__init__.py:821 msgid "Recovery codes already generated" msgstr "" -#: warehouse/manage/views/__init__.py:819 +#: warehouse/manage/views/__init__.py:822 msgid "Generating new recovery codes will invalidate your existing codes." msgstr "" -#: warehouse/manage/views/__init__.py:928 +#: warehouse/manage/views/__init__.py:931 msgid "Verify your email to create an API token." msgstr "" -#: warehouse/manage/views/__init__.py:1028 +#: warehouse/manage/views/__init__.py:1031 msgid "API Token does not exist." msgstr "" -#: warehouse/manage/views/__init__.py:1060 +#: warehouse/manage/views/__init__.py:1063 msgid "Invalid credentials. Try again" msgstr "" -#: warehouse/manage/views/__init__.py:1285 +#: warehouse/manage/views/__init__.py:1182 +msgid "Invalid alternate repository location details" +msgstr "" + +#: warehouse/manage/views/__init__.py:1227 +msgid "Added alternate repository '${name}'" +msgstr "" + +#: warehouse/manage/views/__init__.py:1261 +#: warehouse/manage/views/__init__.py:2162 +#: warehouse/manage/views/__init__.py:2247 +#: warehouse/manage/views/__init__.py:2348 +#: warehouse/manage/views/__init__.py:2448 +msgid "Confirm the request" +msgstr "" + +#: warehouse/manage/views/__init__.py:1273 +msgid "Invalid alternate repository id" +msgstr "" + +#: warehouse/manage/views/__init__.py:1284 +msgid "Invalid alternate repository for project" +msgstr "" + +#: warehouse/manage/views/__init__.py:1292 +msgid "" +"Could not delete alternate repository - ${confirm} is not the same as " +"${alt_repo_name}" +msgstr "" + +#: warehouse/manage/views/__init__.py:1330 +msgid "Deleted alternate repository '${name}'" +msgstr "" + +#: warehouse/manage/views/__init__.py:1461 msgid "" "GitHub-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1398 +#: warehouse/manage/views/__init__.py:1574 msgid "" "GitLab-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1510 +#: warehouse/manage/views/__init__.py:1686 msgid "" "Google-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1619 +#: warehouse/manage/views/__init__.py:1795 msgid "" "ActiveState-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1854 -#: warehouse/manage/views/__init__.py:2155 -#: warehouse/manage/views/__init__.py:2263 +#: warehouse/manage/views/__init__.py:2030 +#: warehouse/manage/views/__init__.py:2331 +#: warehouse/manage/views/__init__.py:2439 msgid "" "Project deletion temporarily disabled. See https://pypi.org/help#admin-" "intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1986 -#: warehouse/manage/views/__init__.py:2071 -#: warehouse/manage/views/__init__.py:2172 -#: warehouse/manage/views/__init__.py:2272 -msgid "Confirm the request" -msgstr "" - -#: warehouse/manage/views/__init__.py:1998 +#: warehouse/manage/views/__init__.py:2174 msgid "Could not yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:2083 +#: warehouse/manage/views/__init__.py:2259 msgid "Could not un-yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:2184 +#: warehouse/manage/views/__init__.py:2360 msgid "Could not delete release - " msgstr "" -#: warehouse/manage/views/__init__.py:2284 +#: warehouse/manage/views/__init__.py:2460 msgid "Could not find file" msgstr "" -#: warehouse/manage/views/__init__.py:2288 +#: warehouse/manage/views/__init__.py:2464 msgid "Could not delete file - " msgstr "" -#: warehouse/manage/views/__init__.py:2438 +#: warehouse/manage/views/__init__.py:2614 msgid "Team '${team_name}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2545 +#: warehouse/manage/views/__init__.py:2721 msgid "User '${username}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2612 +#: warehouse/manage/views/__init__.py:2788 msgid "${username} is now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/manage/views/__init__.py:2644 +#: warehouse/manage/views/__init__.py:2820 msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for project" msgstr "" -#: warehouse/manage/views/__init__.py:2657 +#: warehouse/manage/views/__init__.py:2833 #: warehouse/manage/views/organizations.py:878 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" -#: warehouse/manage/views/__init__.py:2722 +#: warehouse/manage/views/__init__.py:2898 #: warehouse/manage/views/organizations.py:943 msgid "Invitation sent to '${username}'" msgstr "" -#: warehouse/manage/views/__init__.py:2755 +#: warehouse/manage/views/__init__.py:2931 msgid "Could not find role invitation." msgstr "" -#: warehouse/manage/views/__init__.py:2766 +#: warehouse/manage/views/__init__.py:2942 msgid "Invitation already expired." msgstr "" -#: warehouse/manage/views/__init__.py:2798 +#: warehouse/manage/views/__init__.py:2974 #: warehouse/manage/views/organizations.py:1130 msgid "Invitation revoked from '${username}'." msgstr "" @@ -1034,7 +1080,7 @@ msgstr "" #: warehouse/templates/manage/project/release.html:188 #: warehouse/templates/manage/project/settings.html:87 #: warehouse/templates/manage/project/settings.html:136 -#: warehouse/templates/manage/project/settings.html:208 +#: warehouse/templates/manage/project/settings.html:357 #: warehouse/templates/manage/team/settings.html:84 msgid "Warning" msgstr "" @@ -1400,6 +1446,9 @@ msgstr "" #: warehouse/templates/manage/project/roles.html:328 #: warehouse/templates/manage/project/roles.html:359 #: warehouse/templates/manage/project/roles.html:380 +#: warehouse/templates/manage/project/settings.html:287 +#: warehouse/templates/manage/project/settings.html:307 +#: warehouse/templates/manage/project/settings.html:327 #: warehouse/templates/manage/team/roles.html:106 #: warehouse/templates/manage/team/settings.html:35 #: warehouse/templates/packaging/submit-malware-observation.html:58 @@ -1650,6 +1699,13 @@ msgstr "" #: warehouse/templates/accounts/register.html:47 #: warehouse/templates/manage/account.html:139 #: warehouse/templates/manage/account.html:480 +#: warehouse/templates/manage/project/history.html:301 +#: warehouse/templates/manage/project/history.html:312 +#: warehouse/templates/manage/project/history.html:323 +#: warehouse/templates/manage/project/history.html:334 +#: warehouse/templates/manage/project/settings.html:224 +#: warehouse/templates/manage/project/settings.html:285 +#: warehouse/templates/manage/project/settings.html:291 #: warehouse/templates/manage/unverified-account.html:112 msgid "Name" msgstr "" @@ -3578,7 +3634,7 @@ msgstr "" #: warehouse/templates/manage/account.html:780 #: warehouse/templates/manage/organization/history.html:201 -#: warehouse/templates/manage/project/history.html:304 +#: warehouse/templates/manage/project/history.html:352 #: warehouse/templates/manage/team/history.html:108 #: warehouse/templates/manage/unverified-account.html:466 msgid "Event" @@ -3587,8 +3643,8 @@ msgstr "" #: warehouse/templates/manage/account.html:781 #: warehouse/templates/manage/organization/history.html:202 #: warehouse/templates/manage/organization/history.html:211 -#: warehouse/templates/manage/project/history.html:305 -#: warehouse/templates/manage/project/history.html:314 +#: warehouse/templates/manage/project/history.html:353 +#: warehouse/templates/manage/project/history.html:362 #: warehouse/templates/manage/team/history.html:109 #: warehouse/templates/manage/team/history.html:118 #: warehouse/templates/manage/unverified-account.html:467 @@ -3615,7 +3671,7 @@ msgstr "" #: warehouse/templates/manage/account.html:795 #: warehouse/templates/manage/organization/history.html:217 -#: warehouse/templates/manage/project/history.html:320 +#: warehouse/templates/manage/project/history.html:368 #: warehouse/templates/manage/team/history.html:124 #: warehouse/templates/manage/unverified-account.html:481 msgid "Device Info" @@ -3948,6 +4004,8 @@ msgstr "" #: warehouse/templates/manage/project/history.html:137 #: warehouse/templates/manage/project/history.html:182 #: warehouse/templates/manage/project/history.html:208 +#: warehouse/templates/manage/project/history.html:299 +#: warehouse/templates/manage/project/history.html:321 #: warehouse/templates/manage/team/history.html:88 msgid "Added by:" msgstr "" @@ -4400,6 +4458,7 @@ msgstr "" #: warehouse/templates/manage/project/publishing.html:275 #: warehouse/templates/manage/project/publishing.html:357 #: warehouse/templates/manage/project/roles.html:341 +#: warehouse/templates/manage/project/settings.html:348 #: warehouse/templates/manage/team/roles.html:131 msgid "Add" msgstr "" @@ -5136,6 +5195,8 @@ msgid "Created by:" msgstr "" #: warehouse/templates/manage/organization/history.html:144 +#: warehouse/templates/manage/project/history.html:310 +#: warehouse/templates/manage/project/history.html:332 #: warehouse/templates/manage/team/history.html:76 msgid "Deleted by:" msgstr "" @@ -5173,7 +5234,7 @@ msgid "Revoked by:" msgstr "" #: warehouse/templates/manage/organization/history.html:198 -#: warehouse/templates/manage/project/history.html:301 +#: warehouse/templates/manage/project/history.html:349 #: warehouse/templates/manage/team/history.html:105 #, python-format msgid "Security history for %(source_name)s" @@ -5439,6 +5500,7 @@ msgstr "" #: warehouse/templates/manage/organization/roles.html:252 #: warehouse/templates/manage/project/release.html:112 #: warehouse/templates/manage/project/releases.html:109 +#: warehouse/templates/manage/project/settings.html:252 msgid "Delete" msgstr "" @@ -5826,11 +5888,31 @@ msgstr "" msgid "Disabled by:" msgstr "" -#: warehouse/templates/manage/project/history.html:306 +#: warehouse/templates/manage/project/history.html:297 +#: warehouse/templates/manage/project/history.html:319 +msgid "Project alternate repository added" +msgstr "" + +#: warehouse/templates/manage/project/history.html:302 +#: warehouse/templates/manage/project/history.html:313 +#: warehouse/templates/manage/project/history.html:324 +#: warehouse/templates/manage/project/history.html:335 +#: warehouse/templates/manage/project/settings.html:225 +#: warehouse/templates/manage/project/settings.html:305 +#: warehouse/templates/manage/project/settings.html:311 +msgid "Url" +msgstr "" + +#: warehouse/templates/manage/project/history.html:308 +#: warehouse/templates/manage/project/history.html:330 +msgid "Project alternate repository deleted" +msgstr "" + +#: warehouse/templates/manage/project/history.html:354 msgid "Additional info" msgstr "" -#: warehouse/templates/manage/project/history.html:318 +#: warehouse/templates/manage/project/history.html:366 #: warehouse/templates/manage/team/history.html:122 msgid "Location info" msgstr "" @@ -6417,7 +6499,7 @@ msgstr "" #: warehouse/templates/manage/project/settings.html:108 #: warehouse/templates/manage/project/settings.html:179 -#: warehouse/templates/manage/project/settings.html:246 +#: warehouse/templates/manage/project/settings.html:395 msgid "Project Name" msgstr "" @@ -6504,16 +6586,74 @@ msgstr "" msgid "You are not an owner or manager of any organizations." msgstr "" -#: warehouse/templates/manage/project/settings.html:206 -#: warehouse/templates/manage/project/settings.html:246 -msgid "Delete project" +#: warehouse/templates/manage/project/settings.html:205 +msgid "Alternate repository locations" msgstr "" #: warehouse/templates/manage/project/settings.html:209 +#, python-format +msgid "" +"Provisional support for PEP 708 \"Alternate " +"Locations\" Metadata." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:213 +#, python-format +msgid "" +"Implementation may change, consider subscribing to pypi-announce to be notified of " +"changes." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:220 +#, python-format +msgid "Alternate repository locations for %(project_name)s" +msgstr "" + +#: warehouse/templates/manage/project/settings.html:226 +msgid "Description" +msgstr "" + +#: warehouse/templates/manage/project/settings.html:245 +#, python-format +msgid "Delete %(name)s from this project." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:253 +msgid "Alternate Repository Name" +msgstr "" + +#: warehouse/templates/manage/project/settings.html:268 +msgid "There are no alternate repositories for this project, yet." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:272 +msgid "Get started by adding an alternate repository below." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:278 +msgid "Add alternate repository location" +msgstr "" + +#: warehouse/templates/manage/project/settings.html:325 +msgid "Alternate repository description" +msgstr "" + +#: warehouse/templates/manage/project/settings.html:331 +#: warehouse/templates/manage/project/settings.html:342 +msgid "Description of the purpose or content of the alternate repository." +msgstr "" + +#: warehouse/templates/manage/project/settings.html:355 +#: warehouse/templates/manage/project/settings.html:395 +msgid "Delete project" +msgstr "" + +#: warehouse/templates/manage/project/settings.html:358 msgid "Deleting this project will:" msgstr "" -#: warehouse/templates/manage/project/settings.html:214 +#: warehouse/templates/manage/project/settings.html:363 #, python-format msgid "" "Irreversibly delete the project along with %(count)s" @@ -6524,15 +6664,15 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/manage/project/settings.html:220 +#: warehouse/templates/manage/project/settings.html:369 msgid "Irreversibly delete the project" msgstr "" -#: warehouse/templates/manage/project/settings.html:224 +#: warehouse/templates/manage/project/settings.html:373 msgid "Make the project name available to any other PyPI user" msgstr "" -#: warehouse/templates/manage/project/settings.html:226 +#: warehouse/templates/manage/project/settings.html:375 msgid "" "This user will be able to make new releases under this project name, so " "long as the distribution filenames do not match filenames from a " diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py index aed856dffa0f..e2588ec576c7 100644 --- a/warehouse/manage/forms.py +++ b/warehouse/manage/forms.py @@ -711,3 +711,52 @@ def validate_name(self, field): class CreateTeamForm(SaveTeamForm): __params__ = SaveTeamForm.__params__ + + +class AddAlternateRepositoryForm(forms.Form): + """Form to add an Alternate Repository Location for a Project.""" + + __params__ = ["display_name", "link_url", "description"] + + display_name = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired( + message=_("Specify your alternate repository name"), + ), + wtforms.validators.Length( + max=100, + message=_( + "The name is too long. " + "Choose a name with 100 characters or less." + ), + ), + ] + ) + link_url = wtforms.URLField( + validators=[ + wtforms.validators.InputRequired( + message=_("Specify your alternate repository URL"), + ), + wtforms.validators.Length( + max=400, + message=_( + "The URL is too long. Choose a URL with 400 characters or less." + ), + ), + forms.URIValidator(), + ] + ) + description = wtforms.TextAreaField( + validators=[ + wtforms.validators.InputRequired( + message="Describe the purpose and content of the alternate repository." + ), + wtforms.validators.Length( + max=400, + message=_( + "The description is too long. " + "Choose a description with 400 characters or less." + ), + ), + ] + ) diff --git a/warehouse/manage/views/__init__.py b/warehouse/manage/views/__init__.py index 9e8cb7cf5175..09653006b703 100644 --- a/warehouse/manage/views/__init__.py +++ b/warehouse/manage/views/__init__.py @@ -12,6 +12,7 @@ import base64 import io +import uuid import pyqrcode @@ -75,6 +76,7 @@ from warehouse.macaroons import caveats from warehouse.macaroons.interfaces import IMacaroonService from warehouse.manage.forms import ( + AddAlternateRepositoryForm, AddEmailForm, ChangePasswordForm, ChangeRoleForm, @@ -126,6 +128,7 @@ TeamRole, ) from warehouse.packaging.models import ( + AlternateRepository, File, JournalEntry, Project, @@ -1123,6 +1126,7 @@ def __init__(self, project, request): self.project = project self.request = request self.transfer_organization_project_form_class = TransferOrganizationProjectForm + self.add_alternate_repository_form_class = AddAlternateRepositoryForm @view_config(request_method="GET") def manage_project_settings(self): @@ -1149,6 +1153,8 @@ def manage_project_settings(self): active_organizations_owned | active_organizations_managed ) - current_organization + add_alt_repo_form = self.add_alternate_repository_form_class() + return { "project": self.project, "MAX_FILESIZE": MAX_FILESIZE, @@ -1158,8 +1164,178 @@ def manage_project_settings(self): organization_choices=organization_choices, ) ), + "add_alternate_repository_form_class": add_alt_repo_form, } + @view_config( + request_method="POST", + request_param=AddAlternateRepositoryForm.__params__ + + ["alternate_repository_location=add"], + require_reauth=True, + permission=Permissions.ProjectsWrite, + ) + def add_project_alternate_repository(self): + form = self.add_alternate_repository_form_class(self.request.POST) + + if not form.validate(): + self.request.session.flash( + self.request._("Invalid alternate repository location details"), + queue="error", + ) + return HTTPSeeOther( + self.request.route_path( + "manage.project.settings", + project_name=self.project.name, + ) + ) + + # add the alternate repository location entry + alt_repo = AlternateRepository( + project=self.project, + name=form.display_name.data, + url=form.link_url.data, + description=form.description.data, + ) + self.request.db.add(alt_repo) + self.request.db.add( + JournalEntry( + name=alt_repo.name, + action=f"add alternate repository {alt_repo.name} " + f"to project {self.project.name}", + submitted_by=self.request.user, + ) + ) + self.project.record_event( + tag=EventTag.Project.AlternateRepositoryAdd, + request=self.request, + additional={ + "added_by": self.request.user.username, + "display_name": alt_repo.name, + "link_url": alt_repo.url, + }, + ) + self.request.user.record_event( + tag=EventTag.Account.AlternateRepositoryAdd, + request=self.request, + additional={ + "added_by": self.request.user.username, + "display_name": alt_repo.name, + "link_url": alt_repo.url, + }, + ) + self.request.session.flash( + self.request._( + "Added alternate repository '${name}'", + mapping={"name": alt_repo.name}, + ), + queue="success", + ) + + return HTTPSeeOther( + self.request.route_path( + "manage.project.settings", + project_name=self.project.name, + ) + ) + + @view_config( + request_method="POST", + request_param=[ + "alternate_repository_id", + "alternate_repository_location=delete", + ], + require_reauth=True, + permission=Permissions.ProjectsWrite, + ) + def delete_project_alternate_repository(self): + confirm_name = self.request.POST.get("confirm_alternate_repository_name") + resp_inst = HTTPSeeOther( + self.request.route_path( + "manage.project.settings", project_name=self.project.name + ) + ) + + # Must confirm alt repo name to delete. + if not confirm_name: + self.request.session.flash( + self.request._("Confirm the request"), queue="error" + ) + return resp_inst + + # Must provide a valid alt repo id. + alternate_repository_id = self.request.POST.get("alternate_repository_id", "") + try: + uuid.UUID(str(alternate_repository_id)) + except ValueError: + alternate_repository_id = None + if not alternate_repository_id: + self.request.session.flash( + self.request._("Invalid alternate repository id"), + queue="error", + ) + return resp_inst + + # The provided alt repo id must be related to this project. + alt_repo: AlternateRepository = self.request.db.get( + AlternateRepository, alternate_repository_id + ) + if not alt_repo or alt_repo not in self.project.alternate_repositories: + self.request.session.flash( + self.request._("Invalid alternate repository for project"), + queue="error", + ) + return resp_inst + + # The confirmed alt repo name must match the provided alt repo id. + if confirm_name != alt_repo.name: + self.request.session.flash( + self.request._( + "Could not delete alternate repository - " + "${confirm} is not the same as ${alt_repo_name}", + mapping={"confirm": confirm_name, "alt_repo_name": alt_repo.name}, + ), + queue="error", + ) + return resp_inst + + # delete the alternate repository location entry + self.request.db.delete(alt_repo) + self.request.db.add( + JournalEntry( + name=alt_repo.name, + action=f"deleted alternate repository {alt_repo.name} " + f"from project {self.project.name}", + submitted_by=self.request.user, + ) + ) + self.project.record_event( + tag=EventTag.Project.AlternateRepositoryDelete, + request=self.request, + additional={ + "deleted_by": self.request.user.username, + "display_name": alt_repo.name, + "link_url": alt_repo.url, + }, + ) + self.request.user.record_event( + tag=EventTag.Account.AlternateRepositoryDelete, + request=self.request, + additional={ + "deleted_by": self.request.user.username, + "display_name": alt_repo.name, + "link_url": alt_repo.url, + }, + ) + self.request.session.flash( + self.request._( + "Deleted alternate repository '${name}'", + mapping={"name": alt_repo.name}, + ), + queue="success", + ) + + return resp_inst + @view_defaults( context=Project, diff --git a/warehouse/migrations/versions/a8050411bc65_add_project_alternate_repositories_table.py b/warehouse/migrations/versions/a8050411bc65_add_project_alternate_repositories_table.py new file mode 100644 index 000000000000..a7e401cd2047 --- /dev/null +++ b/warehouse/migrations/versions/a8050411bc65_add_project_alternate_repositories_table.py @@ -0,0 +1,51 @@ +# 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. +""" +add project alternate repositories table + +Revision ID: a8050411bc65 +Revises: +Create Date: 2024-04-25 00:26:09.199573 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "a8050411bc65" +down_revision = "0b74ed7d4880" + + +def upgrade(): + op.create_table( + "alternate_repositories", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("url", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.CheckConstraint( + "url ~* '^https?://.+'::text", name="alternate_repository_valid_url" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("project_id", "name"), + sa.UniqueConstraint("project_id", "url"), + ) + + +def downgrade(): + op.drop_table("alternate_repositories") diff --git a/warehouse/packaging/__init__.py b/warehouse/packaging/__init__.py index a219800967b8..21f4838350b6 100644 --- a/warehouse/packaging/__init__.py +++ b/warehouse/packaging/__init__.py @@ -24,7 +24,7 @@ IProjectService, ISimpleStorage, ) -from warehouse.packaging.models import File, Project, Release, Role +from warehouse.packaging.models import AlternateRepository, File, Project, Release, Role from warehouse.packaging.services import project_service_factory from warehouse.packaging.tasks import ( check_file_cache_tasks_outstanding, @@ -178,6 +178,13 @@ def includeme(config): key_factory("project/{itr.normalized_name}", iterate_on="projects"), ], ) + config.register_origin_cache_keys( + AlternateRepository, + cache_keys=["project/{obj.project.normalized_name}"], + purge_keys=[ + key_factory("project/{obj.project.normalized_name}"), + ], + ) config.add_periodic_task(crontab(minute="*/1"), check_file_cache_tasks_outstanding) diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 4734dbfc954a..3f6f397d8ae9 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -233,6 +233,11 @@ class Project(SitemapMixin, HasEvents, HasObservations, db.Model): order_by=lambda: Release._pypi_ordering.desc(), passive_deletes=True, ) + alternate_repositories: Mapped[list[AlternateRepository]] = orm.relationship( + cascade="all, delete-orphan", + back_populates="project", + passive_deletes=True, + ) __table_args__ = ( CheckConstraint( @@ -1025,3 +1030,34 @@ class ProjectMacaroonWarningAssociation(db.Model): ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"), primary_key=True, ) + + +class AlternateRepository(db.Model): + """ + Store an alternate repository name, url, description for a project. + One project can have zero, one, or more alternate repositories. + + For each project, ensures the url and name are unique. + Urls must start with http(s). + """ + + __tablename__ = "alternate_repositories" + __table_args__ = ( + UniqueConstraint("project_id", "url"), + UniqueConstraint("project_id", "name"), + CheckConstraint( + "url ~* '^https?://.+'::text", + name="alternate_repository_valid_url", + ), + ) + + __repr__ = make_repr("name", "url") + + project_id: Mapped[UUID] = mapped_column( + ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"), + ) + project: Mapped[Project] = orm.relationship(back_populates="alternate_repositories") + + name: Mapped[str] + url: Mapped[str] + description: Mapped[str] diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py index 30c85b3feb50..282c45747f75 100644 --- a/warehouse/packaging/utils.py +++ b/warehouse/packaging/utils.py @@ -22,7 +22,7 @@ from warehouse.packaging.interfaces import ISimpleStorage from warehouse.packaging.models import File, LifecycleStatus, Project, Release -API_VERSION = "1.1" +API_VERSION = "1.2" def _simple_index(request, serial): @@ -63,11 +63,15 @@ def _simple_detail(project, request): versions = sorted( {f.release.version for f in files}, key=packaging_legacy.version.parse ) + alternate_repositories = sorted( + alt_repo.url for alt_repo in project.alternate_repositories + ) return { "meta": {"api-version": API_VERSION, "_last-serial": project.last_serial}, "name": project.normalized_name, "versions": versions, + "alternate-locations": alternate_repositories, "files": [ { "filename": file.filename, @@ -105,6 +109,7 @@ def _simple_detail(project, request): def render_simple_detail(project, request, store=False): context = _simple_detail(project, request) + context = _valid_simple_detail_context(context) env = request.registry.queryUtility(IJinja2Environment, name=".jinja2") template = env.get_template("templates/api/simple/detail.html") @@ -144,3 +149,8 @@ def render_simple_detail(project, request, store=False): ) return (content_hash, simple_detail_path) + + +def _valid_simple_detail_context(context: dict) -> dict: + context["alternate_locations"] = context.pop("alternate-locations", []) + return context diff --git a/warehouse/templates/api/simple/detail.html b/warehouse/templates/api/simple/detail.html index 24b0042c5863..28a8f49c815d 100644 --- a/warehouse/templates/api/simple/detail.html +++ b/warehouse/templates/api/simple/detail.html @@ -15,6 +15,9 @@ + {% for alt_repo in alternate_locations %} + + {% endfor %} Links for {{ name }} diff --git a/warehouse/templates/manage/project/history.html b/warehouse/templates/manage/project/history.html index 91d97a795095..36950a26ed42 100644 --- a/warehouse/templates/manage/project/history.html +++ b/warehouse/templates/manage/project/history.html @@ -291,8 +291,56 @@

{% trans %}Security history{% endtrans %}

{% trans %}Disabled by:{% endtrans %}
{{ event.additional.modified_by }} + + {# Display Project Alternate Repository events #} + {% elif event.tag == EventTag.Account.AlternateRepositoryAdd %} + {% trans%}Project alternate repository added{% endtrans %} + + {% trans %}Added by:{% endtrans %} {{ event.additional.added_by }} + + {% trans %}Name{% endtrans %}: {{ event.additional.display_name }} + {% trans %}Url{% endtrans %}: + + {{ event.additional.link_url }} + + + {% elif event.tag == EventTag.Account.AlternateRepositoryDelete %} + {% trans%}Project alternate repository deleted{% endtrans %} + + {% trans %}Deleted by:{% endtrans %} {{ event.additional.deleted_by }} + + {% trans %}Name{% endtrans %}: {{ event.additional.display_name }} + {% trans %}Url{% endtrans %}: + + {{ event.additional.link_url }} + + + {% elif event.tag == EventTag.Project.AlternateRepositoryAdd %} + {% trans%}Project alternate repository added{% endtrans %} + + {% trans %}Added by:{% endtrans %} {{ event.additional.added_by }} + + {% trans %}Name{% endtrans %}: {{ event.additional.display_name }} + {% trans %}Url{% endtrans %}: + + {{ event.additional.link_url }} + + + {% elif event.tag == EventTag.Project.AlternateRepositoryDelete %} + {% trans%}Project alternate repository deleted{% endtrans %} + + {% trans %}Deleted by:{% endtrans %} {{ event.additional.deleted_by }} + + {% trans %}Name{% endtrans %}: {{ event.additional.display_name }} + {% trans %}Url{% endtrans %}: + + {{ event.additional.link_url }} + + + + {# Show the name of tags that are not catered for above #} {% else %} - {{ event.tag }} + {{ event.tag }} {% endif %} {%- endmacro %} diff --git a/warehouse/templates/manage/project/settings.html b/warehouse/templates/manage/project/settings.html index f180fd43345d..609b684067f6 100644 --- a/warehouse/templates/manage/project/settings.html +++ b/warehouse/templates/manage/project/settings.html @@ -202,6 +202,155 @@


{% endif %} +

{% trans %}Alternate repository locations{% endtrans %}

+ +
+

+ {% trans pep_url="https://peps.python.org/pep-0708/#alternate-locations-metadata" + %}Provisional support for PEP 708 "Alternate Locations" Metadata. {% endtrans %} +

+

+ {% trans pypi_announce_url="https://mail.python.org/mailman3/lists/pypi-announce.python.org/" + %}Implementation may change, consider subscribing to pypi-announce to be notified of changes.{% endtrans %} +

+
+ + {% if project.alternate_repositories %} + + + + + + + + + + + + {% for alternate_repository in project.alternate_repositories|sort(attribute="name") %} + + + + + + + {% endfor %} + +
{% trans project_name=project.name %}Alternate repository locations for + {{ project_name }}{% endtrans %}
{% trans %}Name{% endtrans %}{% trans %}Url{% endtrans %}{% trans %}Description{% endtrans %}
+ {{ alternate_repository.name }} + + + {{ alternate_repository.url }} + + + {{ alternate_repository.description }} + + {% set extra_description %}{% trans name=alternate_repository.name %}Delete {{ name }} from this + project.{% endtrans %}{% endset %} + {% set extra_fields %} + + + {% endset %} + {{ confirm_button( + gettext("Delete"), + gettext("Alternate Repository Name"), + "alternate_repository_name", + alternate_repository.name, + extra_description=extra_description, + action=request.route_path('manage.project.settings', project_name=project.name), + extra_fields=extra_fields, + tooltip=extra_description + ) }} +
+ {% else %} +

+ {% trans %}There are no alternate repositories for this project, yet.{% endtrans %} +

+ {% if request.has_permission(Permissions.ProjectsWrite) %} +

+ {% trans %}Get started by adding an alternate repository below.{% endtrans %} +

+ {% endif %} + {% endif %} + +

+ {% trans %}Add alternate repository location{% endtrans %} +

+
+
+ + + + {{ add_alternate_repository_form_class.display_name( + placeholder=gettext("Name"), + autocomplete="on", + autocapitalize="on", + spellcheck="true", + class_="form-group__field", + aria_describedby="alternate-repository-name-errors", + ) }} +
+ {{ field_errors(add_alternate_repository_form_class.display_name) }} +
+
+ +
+ + {{ add_alternate_repository_form_class.link_url( + placeholder=gettext("Url"), + autocomplete="off", + autocapitalize="off", + spellcheck="false", + class_="form-group__field", + aria_describedby="alternate-repository-url-errors", + ) }} +
+ {{ field_errors(add_alternate_repository_form_class.link_url) }} +
+
+ +
+ + {{ add_alternate_repository_form_class.description( + placeholder=gettext("Description of the purpose or content of the alternate repository."), + autocomplete="off", + autocapitalize="off", + spellcheck="true", + class_="form-group__field", + aria_describedby="alternate-repository-description-errors", + ) }} +
+ {{ field_errors(add_alternate_repository_form_class.description) }} +
+

+ {% trans %} + Description of the purpose or content of the alternate repository. + {% endtrans %} +

+
+
+ +
+
+ +
+

{% trans %}Delete project{% endtrans %}