From c4b8ce37ca5407cbf4a18551de0c431d865b1436 Mon Sep 17 00:00:00 2001 From: Austin Macdonald Date: Fri, 25 May 2018 10:25:51 -0400 Subject: [PATCH] Add hypermedia API to replace XML-RPC and simple Adds a new API that covers the usage of the XMP-RPC and the simple api. https://github.com/pypa/warehouse/issues/284 This work is intended as a proof of concept for how a hypermedia API could be implemented, setting up the patterns that can be extended to cover the rest of the API. The new API introduces pagination to reduce the load for list views. Serializers are used to increase maintainability and code reuse. Some filtering is added to meet the use cases of XML-RPC. Many thanks to @werwty for hacking out an initial implementation which has been squashed. Introduces new dependencies: apispec==0.37.0 : Used to generate an api spec at /api/ marshmallow==3.0.0b10 : Used to serialize responses PyYAML==3.12 : Dependency of apispec All new endpoints are added to a new domain, "sandbox". Note: Locally, all subdomains were treated just like the actual domain so I was unable to make the subdomain works as expected. I followed the pattern that forklift uses, and guessed how it should work. --- dev/environment | 2 + requirements/main.in | 3 + requirements/main.txt | 6 + tests/unit/test_config.py | 1 + warehouse/api/__init__.py | 15 +++ warehouse/api/routes.py | 92 +++++++++++++++ warehouse/api/schema.py | 195 ++++++++++++++++++++++++++++++ warehouse/api/spec.py | 108 +++++++++++++++++ warehouse/api/utils.py | 38 ++++++ warehouse/api/views.py | 243 ++++++++++++++++++++++++++++++++++++++ warehouse/config.py | 4 + 11 files changed, 707 insertions(+) create mode 100644 warehouse/api/__init__.py create mode 100644 warehouse/api/routes.py create mode 100644 warehouse/api/schema.py create mode 100644 warehouse/api/spec.py create mode 100644 warehouse/api/utils.py create mode 100644 warehouse/api/views.py diff --git a/dev/environment b/dev/environment index 1f2a8a2ae1b0..710800697823 100644 --- a/dev/environment +++ b/dev/environment @@ -38,3 +38,5 @@ TOKEN_EMAIL_SECRET="an insecure email verification secret key" TOKEN_TWO_FACTOR_SECRET="an insecure two-factor auth secret key" WAREHOUSE_LEGACY_DOMAIN=pypi.python.org + +HYPERMEDIA_API=api.pypi.org diff --git a/requirements/main.in b/requirements/main.in index 1f1373b8a432..d3a84b9e4456 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -1,4 +1,5 @@ alembic>=0.7.0 +apispec Automat argon2-cffi Babel @@ -21,6 +22,7 @@ itsdangerous Jinja2>=2.8 limits lxml +marshmallow mistune msgpack packaging>=15.2 @@ -39,6 +41,7 @@ pyramid_retry>=0.3 pyramid_rpc>=0.7 pyramid_services>=2.1 pyramid_tm>=0.12 +PyYaml raven readme_renderer[md]>=0.7.0 requests diff --git a/requirements/main.txt b/requirements/main.txt index 9ef2b0ed702e..81de943b44d6 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -3,6 +3,8 @@ alembic==1.0.10 \ amqp==2.4.2 \ --hash=sha256:043beb485774ca69718a35602089e524f87168268f0d1ae115f28b88d27f92d7 \ --hash=sha256:35a3b5006ca00b21aaeec8ceea07130f07b902dd61bfe42815039835f962f5f1 +apispec==0.37.0 \ + --hash=sha256:8c497b70f0095da521b41ea1e85cd171302b80e4bb6382cb0055cadf4980ced1 argon2-cffi==19.1.0 \ --hash=sha256:1029fef2f7808a89e3baa306f5ace36e768a2d847ee7b056399adcd7707f6256 \ --hash=sha256:206857d870c6ca3c92514ca70a3c371be47383f7ae6a448f5a16aa17baa550ba \ @@ -311,6 +313,8 @@ markupsafe==1.1.1 \ --hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \ --hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \ --hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 +marshmallow==3.0.0b10 \ + --hash=sha256:be2541dfd0fe7fdbb6ab83ab187e5190dfe2e169b68bb6ff982b06fad5bdb7e0 mistune==0.8.4 \ --hash=sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e \ --hash=sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4 @@ -491,6 +495,8 @@ python-editor==1.0.4 \ pytz==2019.1 \ --hash=sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda \ --hash=sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141 +PyYAML==3.12 \ + --hash=sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab raven==6.10.0 \ --hash=sha256:3fa6de6efa2493a7c827472e984ce9b020797d0da16f1db67197bcc23c8fae54 \ --hash=sha256:44a13f87670836e153951af9a3c80405d36b43097db869a36e92809673692ce4 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 59ea968a4613..0d95248fb755 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -276,6 +276,7 @@ def __init__(self): ] assert configurator_obj.include.calls == ( [ + pretend.call(".api") pretend.call("pyramid_services"), pretend.call(".metrics"), pretend.call(".csrf"), diff --git a/warehouse/api/__init__.py b/warehouse/api/__init__.py new file mode 100644 index 000000000000..6604331f9de2 --- /dev/null +++ b/warehouse/api/__init__.py @@ -0,0 +1,15 @@ +# 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. + + +def includeme(config): + config.include(".routes") diff --git a/warehouse/api/routes.py b/warehouse/api/routes.py new file mode 100644 index 000000000000..2da1a661ac3d --- /dev/null +++ b/warehouse/api/routes.py @@ -0,0 +1,92 @@ +# 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. + + +def includeme(config): + # Add a subdomain for the hypermedia api. + hypermedia = config.get_settings().get("hypermedia.domain") + + config.add_route("api.spec", "/api/", read_only=True, domain=hypermedia) + config.add_route( + "api.views.projects", + "/api/projects/", + factory="warehouse.packaging.models:ProjectFactory", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.projects.detail", + "/api/projects/{name}/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.projects.detail.files", + "/api/projects/{name}/files/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.projects.releases", + "/api/projects/{name}/releases/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.projects.releases.detail", + "/api/projects/{name}/releases/{version}/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}/{version}", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.projects.releases.files", + "/api/projects/{name}/releases/{version}/files/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}/{version}", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.projects.detail.roles", + "/api/projects/{name}/roles/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=hypermedia, + ) + config.add_route( + "api.views.journals", "/api/journals/", read_only=True, domain=hypermedia + ) + # This is the JSON API equivalent of changelog_last_serial() + config.add_route( + "api.views.journals.latest", + "/api/journals/latest/", + read_only=True, + domain=hypermedia, + ) + # This is the JSON API equivalent of user_packages(user) + config.add_route( + "api.views.users.details.projects", + "/api/users/{user}/projects/", + factory="warehouse.accounts.models:UserFactory", + traverse="/{user}", + read_only=True, + domain=hypermedia, + ) diff --git a/warehouse/api/schema.py b/warehouse/api/schema.py new file mode 100644 index 000000000000..4ade2c0870a9 --- /dev/null +++ b/warehouse/api/schema.py @@ -0,0 +1,195 @@ +# 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. + +import datetime + +from marshmallow import Schema, fields + + +class File(Schema): + filename = fields.Str() + packagetype = fields.Str() + python_version = fields.Str() + has_sig = fields.Bool(attribute="has_signature") + comment_text = fields.Str() + md5_digest = fields.Str() + digests = fields.Method("get_digests") + size = fields.Int() + upload_time = fields.Function( + lambda obj: obj.upload_time.strftime("%Y-%m-%dT%H:%M:%S") + ) + url = fields.Method("get_detail_url") + + def get_digests(self, obj): + return {"md5": obj.md5_digest, "sha256": obj.sha256_digest} + + def get_detail_url(self, obj): + request = self.context.get("request") + return request.route_url("packaging.file", path=obj.path) + + +class Release(Schema): + bugtrack_url = fields.Str(attribute="project.bugtrack_url") + classifiers = fields.List(fields.Str()) + docs_url = fields.Str(attribute="project.documentation_url") + downloads = fields.Method("get_downloads") + project_url = fields.Method("get_project_url") + url = fields.Method("get_release_url") + requires_dist = fields.List(fields.Str()) + files_url = fields.Method("get_files_url") + + def get_files_url(self, obj): + request = self.context.get("request") + return request.route_url( + "api.views.projects.releases.files", + name=obj.project.name, + version=obj.version, + ) + + def get_project_url(self, obj): + request = self.context.get("request") + return request.route_url("api.views.projects.detail", name=obj.project.name) + + def get_release_url(self, obj): + request = self.context.get("request") + return request.route_url( + "api.views.projects.releases.detail", + name=obj.project.name, + version=obj.version, + ) + + def get_downloads(self, obj): + return {"last_day": -1, "last_week": -1, "last_month": -1} + + class Meta: + fields = ( + "author", + "author_email", + "bugtrack_url", + "classifiers", + "description", + "description_content_type", + "docs_url", + "downloads", + "download_url", + "home_page", + "keywords", + "license", + "maintainer", + "maintainer_email", + "name", + "project_url", + "url", + "platform", + "requires_dist", + "requires_python", + "summary", + "version", + "files_url", + ) + ordered = True + + +class Project(Schema): + url = fields.Method("get_detail_url") + releases_url = fields.Method("get_releases_url") + latest_version_url = fields.Method("get_latest_version_url") + legacy_project_json = fields.Method("get_legacy_project_json") + roles_url = fields.Method("get_roles_url") + files_url = fields.Method("get_files_url") + + def get_files_url(self, obj): + request = self.context.get("request") + return request.route_url("api.views.projects.detail.files", name=obj.name) + + def get_roles_url(self, obj): + request = self.context.get("request") + return request.route_url( + "api.views.projects.detail.roles", name=obj.normalized_name + ) + + def get_legacy_project_json(self, obj): + request = self.context.get("request") + return request.route_url("legacy.api.json.project", name=obj.normalized_name) + + def get_detail_url(self, obj): + request = self.context.get("request") + return request.route_url("api.views.projects.detail", name=obj.normalized_name) + + def get_latest_version_url(self, obj): + request = self.context.get("request") + if not obj.latest_version: + return None + return request.route_url( + "api.views.projects.releases.detail", + name=obj.name, + version=obj.latest_version[0], + ) + + def get_releases_url(self, obj): + request = self.context.get("request") + return request.route_url( + "api.views.projects.releases", name=obj.normalized_name + ) + + class Meta: + fields = ( + "name", + "normalized_name", + "latest_version_url", + "bugtrack_url", + "last_serial", + "url", + "releases_url", + "legacy_project_json", + "stable_version", + "created", + "roles_url", + "files_url", + ) + + +class Journal(Schema): + project_name = fields.Str(attribute="name") + timestamp = fields.Method("get_timestamp") + release_url = fields.Method("get_release_url") + + def get_release_url(self, obj): + request = self.context.get("request") + if not obj.version: + return None + return request.route_url( + "api.views.projects.releases.detail", name=obj.name, version=obj.version + ) + + def get_timestamp(self, obj): + return int(obj.submitted_date.replace(tzinfo=datetime.timezone.utc).timestamp()) + + class Meta: + fields = ( + "project_name", + "release_url", + "version", + "timestamp", + "action", + "submitted_date", + ) + + +class Role(Schema): + role = fields.Str(attribute="role_name") + name = fields.Str(attribute="user.username") + + +class UserProjects(Schema): + role = fields.Str(attribute="role_name") + project = fields.Nested(Project(only=("name", "url"))) diff --git a/warehouse/api/spec.py b/warehouse/api/spec.py new file mode 100644 index 000000000000..1b734d6c434f --- /dev/null +++ b/warehouse/api/spec.py @@ -0,0 +1,108 @@ +# 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 apispec import APISpec + +from warehouse.api import schema + +hypermedia_spec = APISpec( + title="Hypermedia API", + version="0.0.0", + info=dict(description="A resource based hypermedia API"), + plugins=["apispec.ext.marshmallow"], +) +hypermedia_spec.definition("project", schema=schema.Project) +hypermedia_spec.definition("release", schema=schema.Release) +hypermedia_spec.definition("journal", schema=schema.Journal) +hypermedia_spec.definition("roles", schema=schema.Role) + +hypermedia_spec.add_path( + path="/projects/", + operations=dict( + get=dict( + description="Return a paginated list of all projects", + responses={"200": {"schema": {"$ref": "#/definitions/project"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/", + operations=dict( + get=dict( + description="Return details of a specific project", + responses={"200": {"schema": {"$ref": "#/definitions/project"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/releases/", + operations=dict( + get=dict( + description="Return a list of all releases of a project", + responses={"200": {"schema": {"$ref": "#/definitions/release"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/releases/{version}/", + operations=dict( + get=dict( + decription="Return a single version of a project", + responses={"200": {"schema": {"$ref": "#/definitions/release"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/releases/{version}/files/", + operations=dict( + get=dict( + decription="Returns files of this version of the project", + responses={"200": {"schema": {"$ref": "#/definitions/release"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/roles/", + operations=dict( + get=dict( + description="Return a list of user roles for this project", + responses={"200": {"schema": {"$ref": "#/definitions/roles"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/journals/", + operations=dict( + get=dict( + description="Return a paginated list of all changes", + responses={"200": {"schema": {"$ref": "#/definitions/journal"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/journals/latest/", + operations=dict( + get=dict( + description="Return the id of most recent change", + responses={"200": {"schema": {"$ref": "#/definitions/journal"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/users/{user}/projects/", + operations=dict( + get=dict( + description="Return the projects of a specific user", + responses={"200": {"schema": {"$ref": "#/definitions/project"}}}, + ) + ), +) diff --git a/warehouse/api/utils.py b/warehouse/api/utils.py new file mode 100644 index 000000000000..89fa9dfb20ea --- /dev/null +++ b/warehouse/api/utils.py @@ -0,0 +1,38 @@ +# 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. + + +def pagination_serializer(schema, data, route, request): + extra_filters = "" + for key, value in request.params.items(): + if key != "page": + extra_filters = "{filters}&{key}={value}".format( + filters=extra_filters, key=key, value=value + ) + resource_url = request.route_url(route) + url_template = "{url}?page={page}{extra_filters}" + + next_page = None + if data.next_page: + next_page = url_template.format( + url=resource_url, page=data.next_page, extra_filters=extra_filters + ) + previous_page = None + if data.previous_page: + previous_page = url_template.format( + url=resource_url, page=data.previous_page, extra_filters=extra_filters + ) + + return { + "data": schema.dump(data), + "links": {"next_page": next_page, "previous_page": previous_page}, + } diff --git a/warehouse/api/views.py b/warehouse/api/views.py new file mode 100644 index 000000000000..d78888e2edec --- /dev/null +++ b/warehouse/api/views.py @@ -0,0 +1,243 @@ +# 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. + +import datetime +from packaging.version import parse + +from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage +from pyramid.view import view_config +from sqlalchemy import func, orm + +from warehouse.packaging import models +from warehouse.utils.paginate import paginate_url_factory +from warehouse.api import schema +from warehouse.api import spec +from warehouse.api.utils import pagination_serializer + +# Should this move to a config? +ITEMS_PER_PAGE = 100 + + +@view_config(route_name="api.spec", renderer="json") +def api_spec(request): + return spec.hypermedia_spec.to_dict() + + +@view_config(route_name="api.views.projects", renderer="json") +def projects(request): + """ + Return a paginated list of all projects, serialized minimally. + + Replaces simple API: /simple/ + Replaces XML-RPC: list_packages() + + Filters: + serial_since: Limits the response to projects that have been updated + since the provided serial. + page_num: Specifies the page to start with. If not provided, the + response begins at page 1. + """ + serial_since = request.params.get("serial_since") + serial = request.params.get("serial") + + page_num = int(request.params.get("page", 1)) + query = request.db.query(models.Project).order_by(models.Project.created) + + if serial_since: + query = query.filter(models.Project.last_serial >= serial_since) + if serial: + query = query.filter(models.Project.last_serial == serial) + + projects_page = SQLAlchemyORMPage( + query, + page=page_num, + items_per_page=ITEMS_PER_PAGE, + url_maker=paginate_url_factory(request), + ) + project_schema = schema.Project( + only=("last_serial", "normalized_name", "url", "legacy_project_json"), many=True + ) + project_schema.context = {"request": request} + return pagination_serializer( + project_schema, projects_page, "api.views.projects", request + ) + + +@view_config( + route_name="api.views.projects.detail", renderer="json", context=models.Project +) +def projects_detail(project, request): + """ + Returns a detail view of a single project. + """ + project_schema = schema.Project() + project_schema.context = {"request": request} + return project_schema.dump(project) + + +@view_config( + route_name="api.views.projects.detail.files", + renderer="json", + context=models.Project, +) +def projects_detail_files(project, request): + files = sorted( + request.db.query(models.File) + .options(orm.joinedload(models.File.release)) + .filter( + models.File.name == project.name, + models.File.version.in_( + request.db.query(models.Release) + .filter(models.Release.project == project) + .with_entities(models.Release.version) + ), + ) + .all(), + key=lambda f: (parse(f.version), f.filename), + ) + serializer = schema.File(many=True, only=("filename", "url")) + serializer.context = {"request": request} + return serializer.dump(files) + + +@view_config( + route_name="api.views.projects.releases", renderer="json", context=models.Project +) +def project_releases(project, request): + releases = ( + request.db.query(models.Release) + .filter(models.Release.project == project) + .order_by( + models.Release.is_prerelease.nullslast(), + models.Release._pypi_ordering.desc(), + ) + .all() + ) + serializer = schema.Release(many=True, only=("version", "url")) + serializer.context = {"request": request} + return serializer.dump(releases) + + +@view_config(route_name="api.views.projects.releases.detail", renderer="json") +def releases_detail(release, request): + + project = release.project + try: + release = ( + request.db.query(models.Release) + .options(orm.undefer("description")) + .join(models.Project) + .filter( + ( + models.Project.normalized_name + == func.normalize_pep426_name(project.name) + ) + & (models.Release.version == release.version) + ) + .one() + ) + except orm.exc.NoResultFound: + return {} + serializer = schema.Release() + serializer.context = {"request": request} + return serializer.dump(release) + + +@view_config(route_name="api.views.projects.releases.files", renderer="json") +def releases_detail_files(release, request): + + project = release.project + files = ( + request.db.query(models.File) + .join(models.Release) + .join(models.Project) + .filter( + (models.Project.normalized_name == func.normalize_pep426_name(project.name)) + ) + .order_by(models.Release._pypi_ordering.desc(), models.File.filename) + .all() + ) + serializer = schema.File(many=True) + serializer.context = {"request": request} + return serializer.dump(files) + + +@view_config(route_name="api.views.projects.detail.roles", renderer="json") +def projects_detail_roles(project, request): + roles = ( + request.db.query(models.Role) + .join(models.User, models.Project) + .filter( + models.Project.normalized_name == func.normalize_pep426_name(project.name) + ) + .order_by(models.Role.role_name.desc(), models.User.username) + .all() + ) + serializer = schema.Role(many=True) + return serializer.dump(roles) + + +@view_config(route_name="api.views.journals", renderer="json") +def journals(request): + since = request.params.get("since") + updated_releases = request.params.get("updated_releases") + page_num = int(request.params.get("page", 1)) + query = request.db.query(models.JournalEntry).order_by( + models.JournalEntry.submitted_date + ) + + if updated_releases: + query = query.filter(models.JournalEntry.version.isnot(None)) + + if since: + query = query.filter( + models.JournalEntry.submitted_date + > datetime.datetime.utcfromtimestamp(int(since)) + ) + + journals_page = SQLAlchemyORMPage( + query, + page=page_num, + items_per_page=ITEMS_PER_PAGE, + url_maker=paginate_url_factory(request), + ) + serializer = schema.Journal(many=True) + serializer.context = {"request": request} + return pagination_serializer( + serializer, journals_page, "api.views.journals", request + ) + + +@view_config(route_name="api.views.journals.latest", renderer="json") +def journals_latest(request): + last_serial = request.db.query(func.max(models.JournalEntry.id)).scalar() + response = { + "last_serial": last_serial, + "project_url": request.route_url( + "api.views.projects", _query={"serial": last_serial} + ), + } + return response + + +@view_config(route_name="api.views.users.details.projects", renderer="json") +def user_detail_packages(user, request): + roles = ( + request.db.query(models.Role) + .join(models.User, models.Project) + .filter(models.User.username == user.username) + .order_by(models.Role.role_name.desc(), models.Project.name) + .all() + ) + serializer = schema.UserProjects(many=True) + serializer.context["request"] = request + return serializer.dump(roles) diff --git a/warehouse/config.py b/warehouse/config.py index b928d52a6c49..33d1d536975d 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -149,6 +149,7 @@ def configure(settings=None): maybe_set(settings, "warehouse.num_proxies", "WAREHOUSE_NUM_PROXIES", int) maybe_set(settings, "warehouse.theme", "WAREHOUSE_THEME") maybe_set(settings, "warehouse.domain", "WAREHOUSE_DOMAIN") + maybe_set(settings, "hypermedia.domain", "HYPERMEDIA_DOMAIN") maybe_set(settings, "forklift.domain", "FORKLIFT_DOMAIN") maybe_set(settings, "warehouse.legacy_domain", "WAREHOUSE_LEGACY_DOMAIN") maybe_set(settings, "site.name", "SITE_NAME", default="Warehouse") @@ -240,6 +241,9 @@ def configure(settings=None): # Register metrics config.include(".metrics") + # Register Hypermedia API + config.include(".api") + # Register our CSRF support. We do this here, immediately after we've # created the Configurator instance so that we ensure to get our defaults # set ASAP before anything else has a chance to set them and possibly call