From 5f41bcd6a2671fbc551a7bd9bd2dcffbb2cfe6b5 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 | 2 +- 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(+), 1 deletion(-) 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 cfb666cef048..caa2a01d0a14 100644 --- a/dev/environment +++ b/dev/environment @@ -33,3 +33,5 @@ TOKEN_EMAIL_SECRET="an insecure email verification secret key" DATADOG_HOST=notdatadog WAREHOUSE_LEGACY_DOMAIN=pypi.python.org + +HYPERMEDIA_API=api.pypi.org diff --git a/requirements/main.in b/requirements/main.in index e1fcebaf89e0..7d615437c2da 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 @@ -36,6 +38,7 @@ pyramid_retry>=0.3 pyramid_rpc>=0.7 pyramid_services pyramid_tm>=0.12 +PyYaml raven readme_renderer>=0.7.0 requests diff --git a/requirements/main.txt b/requirements/main.txt index fa5b486c8c4a..59606c8dffa9 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -3,6 +3,8 @@ alembic==0.9.9 \ amqp==2.2.2 \ --hash=sha256:4e28d3ea61a64ae61830000c909662cb053642efddbe96503db0e7783a6ee85b \ --hash=sha256:cba1ace9d4ff6049b190d8b7991f9c1006b443a5238021aca96dd6ad2ac9da22 +apispec==0.37.0 \ + --hash=sha256:8c497b70f0095da521b41ea1e85cd171302b80e4bb6382cb0055cadf4980ced1 argon2-cffi==18.1.0 \ --hash=sha256:93f631fa567dbf948f26874476c9e9afb51e0a835372bf1a319df0c5aa071bfb \ --hash=sha256:131effd5eabbe08649bc672b5d602fd6e2772b03cfec2ddb2795f9d9babe3fba \ @@ -269,6 +271,8 @@ Mako==1.0.7 \ --hash=sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae MarkupSafe==1.0 \ --hash=sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665 +marshmallow==3.0.0b10 \ + --hash=sha256:be2541dfd0fe7fdbb6ab83ab187e5190dfe2e169b68bb6ff982b06fad5bdb7e0 mistune==0.8.3 \ --hash=sha256:b4c512ce2fc99e5a62eb95a4aba4b73e5f90264115c40b70a21e1f7d4e0eac91 \ --hash=sha256:bc10c33bfdcaa4e749b779f62f60d6e12f8215c46a292d05e486b869ae306619 @@ -420,6 +424,8 @@ python-editor==1.0.3 \ pytz==2018.4 \ --hash=sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555 \ --hash=sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749 +PyYAML==3.12 \ + --hash=sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab raven==6.8.0 \ --hash=sha256:1c641e5ebc2d4185560608e253970ca0d4b98475f4edf67735015a415f9e1d48 \ --hash=sha256:95aecf76c414facaddbb056f3e98c7936318123e467728f2e50b3a66b65a6ef7 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2387a772347f..336cd32bf424 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -327,7 +327,7 @@ def __init__(self): pretend.call(HostRewrite), ] assert configurator_obj.include.calls == ( - [pretend.call(".datadog"), pretend.call(".csrf")] + [pretend.call(".api"), pretend.call(".datadog"), pretend.call(".csrf")] + [ pretend.call(x) for x in [ 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 c5447e9514ff..f64679cc8702 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -181,6 +181,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") @@ -262,6 +263,9 @@ def configure(settings=None): ) config.add_tween("warehouse.config.unicode_redirect_tween_factory") + # Register Hypermedia API + config.include(".api") + # Register DataDog metrics config.include(".datadog")