diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index 1cad152cb1ea..2c5b3add05bd 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -3091,6 +3091,7 @@ def test_upload_succeeds_creates_release( ] assert set(release.requires_dist) == {"foo", "bar (>1.0)"} assert set(release.project_urls) == {"Test, https://example.com/"} + assert release.project_urls_new == {"Test": "https://example.com/"} assert set(release.requires_external) == {"Cheese (>1.0)"} assert set(release.provides) == {"testing"} assert release.version == expected_version diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index ca4497d88c0a..9777ab4fb1ab 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -1123,6 +1123,16 @@ def file_upload(request): request.db.add(missing_classifier) release_classifiers.append(missing_classifier) + # Parse the Project URLs structure into a key/value dict + project_urls = {} + for urlspec in form.project_urls.data: + name, _, url = urlspec.partition(",") + name = name.strip() + url = url.strip() + + if name and url: + project_urls[name] = url + release = Release( project=project, _classifiers=release_classifiers, @@ -1151,6 +1161,7 @@ def file_upload(request): html=rendered or "", rendered_by=readme.renderer_version(), ), + project_urls_new=project_urls, **{ k: getattr(form, k).data for k in { diff --git a/warehouse/migrations/versions/7a8c380cefa4_add_releaseurl.py b/warehouse/migrations/versions/7a8c380cefa4_add_releaseurl.py new file mode 100644 index 000000000000..66503a14c46c --- /dev/null +++ b/warehouse/migrations/versions/7a8c380cefa4_add_releaseurl.py @@ -0,0 +1,53 @@ +# 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 ReleaseURL + +Revision ID: 7a8c380cefa4 +Revises: d1c00b634ac8 +Create Date: 2022-06-10 22:02:49.522320 +""" + +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "7a8c380cefa4" +down_revision = "d1c00b634ac8" + + +def upgrade(): + op.create_table( + "release_urls", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("release_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("url", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["release_id"], ["releases.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_release_urls_release_id"), "release_urls", ["release_id"], unique=False + ) + + +def downgrade(): + op.drop_index(op.f("ix_release_urls_release_id"), table_name="release_urls") + op.drop_table("release_urls") diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 9d861b27175b..b57df368b272 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -31,6 +31,7 @@ ForeignKey, Index, Integer, + String, Table, Text, UniqueConstraint, @@ -43,6 +44,7 @@ from sqlalchemy.ext.declarative import declared_attr # type: ignore from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import validates +from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from sqlalchemy.sql import expression from trove_classifiers import sorted_classifiers @@ -323,6 +325,21 @@ class Description(db.Model): rendered_by = Column(Text, nullable=False) +class ReleaseURL(db.Model): + + __tablename__ = "release_urls" + __repr__ = make_repr("name", "url") + + release_id = Column( + ForeignKey("releases.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + name = Column(String(32), nullable=False) + url = Column(Text, nullable=False) + + class Release(db.Model): __tablename__ = "releases" @@ -396,6 +413,18 @@ def __table_args__(cls): # noqa ) classifiers = association_proxy("_classifiers", "classifier") + _project_urls_new = orm.relationship( + ReleaseURL, + backref="release", + collection_class=attribute_mapped_collection("name"), + cascade="all, delete-orphan", + order_by=lambda: ReleaseURL.name.asc(), + passive_deletes=True, + ) + project_urls_new = association_proxy( + "_project_urls_new", "url", creator=lambda k, v: ReleaseURL(name=k, url=v) # type: ignore + ) + files = orm.relationship( "File", backref="release",