From 8c1c41865ea96546c48d723ec5b9f0cd9aadd079 Mon Sep 17 00:00:00 2001 From: Jiri Kyjovsky Date: Tue, 11 Jul 2023 17:07:33 +0200 Subject: [PATCH] frontend: migrate API projects namespace to flask-restx --- .../coprs/views/apiv3_ns/__init__.py | 24 +- .../coprs/views/apiv3_ns/apiv3_builds.py | 2 - .../coprs/views/apiv3_ns/apiv3_projects.py | 397 +++++++++++------- .../coprs/views/apiv3_ns/schema.py | 128 +++++- 4 files changed, 382 insertions(+), 169 deletions(-) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py index d15e9f307..7273bb61e 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py @@ -1,14 +1,13 @@ import json + import flask import wtforms import sqlalchemy import inspect from functools import wraps from werkzeug.datastructures import ImmutableMultiDict, MultiDict -from werkzeug.exceptions import HTTPException, NotFound, GatewayTimeout from sqlalchemy.orm.attributes import InstrumentedAttribute -from flask_restx import Api, Namespace, Resource -from coprs import app +from flask_restx import Api from coprs.exceptions import ( AccessRestricted, ActionInProgressException, @@ -234,7 +233,7 @@ def get(self): def editable_copr(f): @wraps(f) - def wrapper(ownername, projectname, **kwargs): + def wrapper(ownername, projectname): copr = get_copr(ownername, projectname) if not flask.g.user.can_edit(copr): raise AccessRestricted( @@ -244,10 +243,25 @@ def wrapper(ownername, projectname, **kwargs): '/'.join([ownername, projectname]) ) ) - return f(copr, **kwargs) + return f(copr) return wrapper +# TODO: sync editable_copr decorator with flask-restx once everything's migrated +def get_editable_copr(func): + """ + Apply @editable_copr decorator on endpoints migrated to flask-restx. + """ + @wraps(func) + def editable_copr_getter(*args, ownername, projectname): + def get_copr_from_dec(return_copr_from_decorator): + return return_copr_from_decorator + + copr = editable_copr(get_copr_from_dec)(ownername, projectname) + return func(*args, copr) + return editable_copr_getter + + def set_defaults(formdata, form_class): """ Take a `formdata` which can be `flask.request.form` or an output from diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py index 83a352ece..3cfd5e051 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py @@ -38,8 +38,6 @@ from .json2form import get_form_compatible_data - - apiv3_builds_ns = Namespace("build", description="Builds") api.add_namespace(apiv3_builds_ns) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py index dd5e99133..741e68291 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py @@ -1,10 +1,17 @@ +from http import HTTPStatus + import flask + +from flask_restx import Namespace, Resource + from coprs.views.apiv3_ns import (query_params, get_copr, pagination, Paginator, - GET, POST, PUT, DELETE, set_defaults) + GET, set_defaults) from coprs.views.apiv3_ns.json2form import get_form_compatible_data, get_input_dict from coprs import db, models, forms, db_session_scope from coprs.views.misc import api_login_required -from coprs.views.apiv3_ns import apiv3_ns, rename_fields_helper +from coprs.views.apiv3_ns import apiv3_ns, rename_fields_helper, api +from coprs.views.apiv3_ns.schema import (fullname_params, project_model, get_project_parser, + ownername_field, project_parser) from coprs.logic.actions_logic import ActionsLogic from coprs.logic.coprs_logic import CoprsLogic, CoprChrootsLogic, MockChrootsLogic from coprs.logic.complex_logic import ComplexLogic @@ -13,7 +20,11 @@ NonAdminCannotDisableAutoPrunning, ActionInProgressException, InsufficientRightsException, BadRequest, ObjectNotFound, InvalidForm) -from . import editable_copr +from . import get_editable_copr + + +apiv3_projects_ns = Namespace("project", description="Projects") +api.add_namespace(apiv3_projects_ns) def to_dict(copr): @@ -78,11 +89,22 @@ def owner2tuple(ownername): return user, group -@apiv3_ns.route("/project", methods=GET) -@query_params() -def get_project(ownername, projectname): - copr = get_copr(ownername, projectname) - return flask.jsonify(to_dict(copr)) +@apiv3_projects_ns.route("/") +class Project(Resource): # pylint: disable=missing-class-docstring + parser = get_project_parser() + + @apiv3_projects_ns.doc(fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(parser) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "OK, Project data follows...") + def get(self): + """ + Get a project + Get details for a single Copr project. + """ + args = self.parser.parse_args() + copr = get_copr(args.ownername, args.projectname) + return to_dict(copr) @apiv3_ns.route("/project/list", methods=GET) @@ -111,168 +133,221 @@ def search_projects(query, **kwargs): return flask.jsonify(items=projects, meta=paginator.meta) -@apiv3_ns.route("/project/add/", methods=POST) -@api_login_required -def add_project(ownername): - user, group = owner2tuple(ownername) - data = rename_fields(get_form_compatible_data(preserve=["chroots"])) - form_class = forms.CoprFormFactory.create_form_cls(user=user, group=group) - set_defaults(data, form_class) - form = form_class(data, meta={'csrf': False}) - - if not form.validate_on_submit(): - raise InvalidForm(form) - validate_chroots(get_input_dict(), MockChrootsLogic.get_multiple()) - - bootstrap = None - # backward compatibility - use_bootstrap_container = form.use_bootstrap_container.data - if use_bootstrap_container is not None: - bootstrap = "on" if use_bootstrap_container else "off" - if form.bootstrap.data is not None: - bootstrap = form.bootstrap.data - - try: - - def _form_field_repos(form_field): - return " ".join(form_field.data.split()) - - copr = CoprsLogic.add( - name=form.name.data.strip(), - repos=_form_field_repos(form.repos), - user=user, - selected_chroots=form.selected_chroots, - description=form.description.data, - instructions=form.instructions.data, - check_for_duplicates=True, - unlisted_on_hp=form.unlisted_on_hp.data, - build_enable_net=form.enable_net.data, - group=group, - persistent=form.persistent.data, - auto_prune=form.auto_prune.data, - bootstrap=bootstrap, - isolation=form.isolation.data, - homepage=form.homepage.data, - contact=form.contact.data, - disable_createrepo=form.disable_createrepo.data, - delete_after_days=form.delete_after_days.data, - multilib=form.multilib.data, - module_hotfixes=form.module_hotfixes.data, - fedora_review=form.fedora_review.data, - follow_fedora_branching=form.follow_fedora_branching.data, - runtime_dependencies=_form_field_repos(form.runtime_dependencies), - appstream=form.appstream.data, - packit_forge_projects_allowed=_form_field_repos(form.packit_forge_projects_allowed), - repo_priority=form.repo_priority.data, - ) - db.session.commit() - except (DuplicateException, - NonAdminCannotCreatePersistentProject, - NonAdminCannotDisableAutoPrunning) as err: - db.session.rollback() - raise err - return flask.jsonify(to_dict(copr)) - - -@apiv3_ns.route("/project/edit//", methods=PUT) -@api_login_required -def edit_project(ownername, projectname): - copr = get_copr(ownername, projectname) - data = rename_fields(get_form_compatible_data(preserve=["chroots"])) - form = forms.CoprForm(data, meta={'csrf': False}) - - if not form.validate_on_submit(): - raise InvalidForm(form) - validate_chroots(get_input_dict(), MockChrootsLogic.get_multiple()) - - for field in form: - if field.data is None or field.name in ["csrf_token", "chroots"]: - continue - if field.name not in data.keys(): - continue - setattr(copr, field.name, field.data) - - if form.chroots.data: - CoprChrootsLogic.update_from_names( - flask.g.user, copr, form.chroots.data) - - try: - CoprsLogic.update(flask.g.user, copr) - if copr.group: # load group.id - _ = copr.group.id - db.session.commit() - except (ActionInProgressException, - InsufficientRightsException, - NonAdminCannotDisableAutoPrunning) as ex: - db.session.rollback() - raise ex - - return flask.jsonify(to_dict(copr)) - - -@apiv3_ns.route("/project/fork//", methods=PUT) -@api_login_required -def fork_project(ownername, projectname): - copr = get_copr(ownername, projectname) - - # @FIXME we want "ownername" from the outside, but our internal Form expects "owner" instead - data = get_form_compatible_data(preserve=["chroots"]) - data["owner"] = data.get("ownername") - - form = forms.CoprForkFormFactory \ - .create_form_cls(copr=copr, user=flask.g.user, groups=flask.g.user.user_groups)(data, meta={'csrf': False}) +@apiv3_projects_ns.route("/add/") +class ProjectAdd(Resource): # pylint: disable=missing-class-docstring + parser = project_parser() + + @api_login_required + @apiv3_projects_ns.doc({"ownername": ownername_field.description}) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(parser) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project created") + def post(self, ownername): + """ + Create new Copr project + """ + + user, group = owner2tuple(ownername) + data = rename_fields(get_form_compatible_data(preserve=["chroots"])) + form_class = forms.CoprFormFactory.create_form_cls(user=user, group=group) + set_defaults(data, form_class) + form = form_class(data, meta={'csrf': False}) + + if not form.validate_on_submit(): + # TODO: we should return error codes with message instead exceptions + raise InvalidForm(form) + validate_chroots(get_input_dict(), MockChrootsLogic.get_multiple()) + + bootstrap = None + # backward compatibility + use_bootstrap_container = form.use_bootstrap_container.data + if use_bootstrap_container is not None: + bootstrap = "on" if use_bootstrap_container else "off" + if form.bootstrap.data is not None: + bootstrap = form.bootstrap.data - if form.validate_on_submit() and copr: try: - dstgroup = ([g for g in flask.g.user.user_groups if g.at_name == form.owner.data] or [None])[0] - if flask.g.user.name != form.owner.data and not dstgroup: - return ObjectNotFound("There is no such group: {}".format(form.owner.data)) - - dst_copr = CoprsLogic.get(flask.g.user.name, form.name.data).all() - if dst_copr and form.confirm.data != True: - raise BadRequest("You are about to fork into existing project: {}\n" - "Please use --confirm if you really want to do this".format(form.name.data)) - fcopr, _ = ComplexLogic.fork_copr(copr, flask.g.user, dstname=form.name.data, - dstgroup=dstgroup) - db.session.commit() - except (ActionInProgressException, InsufficientRightsException) as err: + def _form_field_repos(form_field): + return " ".join(form_field.data.split()) + + copr = CoprsLogic.add( + name=form.name.data.strip(), + repos=_form_field_repos(form.repos), + user=user, + selected_chroots=form.selected_chroots, + description=form.description.data, + instructions=form.instructions.data, + check_for_duplicates=True, + unlisted_on_hp=form.unlisted_on_hp.data, + build_enable_net=form.enable_net.data, + group=group, + persistent=form.persistent.data, + auto_prune=form.auto_prune.data, + bootstrap=bootstrap, + isolation=form.isolation.data, + homepage=form.homepage.data, + contact=form.contact.data, + disable_createrepo=form.disable_createrepo.data, + delete_after_days=form.delete_after_days.data, + multilib=form.multilib.data, + module_hotfixes=form.module_hotfixes.data, + fedora_review=form.fedora_review.data, + follow_fedora_branching=form.follow_fedora_branching.data, + runtime_dependencies=_form_field_repos(form.runtime_dependencies), + appstream=form.appstream.data, + packit_forge_projects_allowed=_form_field_repos(form.packit_forge_projects_allowed), + repo_priority=form.repo_priority.data, + ) + db.session.commit() + except (DuplicateException, + NonAdminCannotCreatePersistentProject, + NonAdminCannotDisableAutoPrunning) as err: db.session.rollback() raise err - else: - raise InvalidForm(form) - - return flask.jsonify(to_dict(fcopr)) - -@apiv3_ns.route("/project/delete//", methods=DELETE) -@api_login_required -def delete_project(ownername, projectname): - copr = get_copr(ownername, projectname) - copr_dict = to_dict(copr) - form = forms.APICoprDeleteForm(meta={'csrf': False}) + return to_dict(copr) + + +@apiv3_projects_ns.route("/edit//") +class ProjectEdit(Resource): # pylint: disable=missing-class-docstring + parser = project_parser() + + @api_login_required + @apiv3_projects_ns.doc(fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(parser) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project successfully edited") + def post(self, ownername, projectname): + """ + Edit Copr project + Edit existing Copr project. + """ + copr = get_copr(ownername, projectname) + data = rename_fields(get_form_compatible_data(preserve=["chroots"])) + form = forms.CoprForm(data, meta={'csrf': False}) + + if not form.validate_on_submit(): + raise InvalidForm(form) + validate_chroots(get_input_dict(), MockChrootsLogic.get_multiple()) + + for field in form: + if field.data is None or field.name in ["csrf_token", "chroots"]: + continue + if field.name not in data.keys(): + continue + setattr(copr, field.name, field.data) + + if form.chroots.data: + CoprChrootsLogic.update_from_names( + flask.g.user, copr, form.chroots.data) - if form.validate_on_submit() and copr: try: - ComplexLogic.delete_copr(copr) + CoprsLogic.update(flask.g.user, copr) + if copr.group: # load group.id + _ = copr.group.id + db.session.commit() except (ActionInProgressException, - InsufficientRightsException) as err: + InsufficientRightsException, + NonAdminCannotDisableAutoPrunning) as ex: db.session.rollback() - raise err + raise ex + + # TODO: apiv3_projects_ns.expect expects here projectname... why? + return to_dict(copr) | {"projectname": projectname} + + +@apiv3_projects_ns.route("/fork//") +class ProjectFork(Resource): # pylint: disable=missing-class-docstring + parser = project_parser() + + @api_login_required + @apiv3_projects_ns.doc(fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(parser) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project is forking...") + def put(self, ownername, projectname): + """ + Fork Copr project + """ + copr = get_copr(ownername, projectname) + + # @FIXME we want "ownername" from the outside, but our internal Form expects "owner" instead + data = get_form_compatible_data(preserve=["chroots"]) + data["owner"] = data.get("ownername") + + form = forms.CoprForkFormFactory \ + .create_form_cls(copr=copr, user=flask.g.user, groups=flask.g.user.user_groups)(data, meta={'csrf': False}) + + if form.validate_on_submit() and copr: + try: + dstgroup = ([g for g in flask.g.user.user_groups if g.at_name == form.owner.data] or [None])[0] + if flask.g.user.name != form.owner.data and not dstgroup: + return ObjectNotFound("There is no such group: {}".format(form.owner.data)) + + dst_copr = CoprsLogic.get(flask.g.user.name, form.name.data).all() + if dst_copr and not form.confirm.data: + raise BadRequest("You are about to fork into existing project: {}\n" + "Please use --confirm if you really want to do this".format(form.name.data)) + fcopr, _ = ComplexLogic.fork_copr(copr, flask.g.user, dstname=form.name.data, + dstgroup=dstgroup) + db.session.commit() + + except (ActionInProgressException, InsufficientRightsException) as err: + db.session.rollback() + raise err else: - db.session.commit() - else: - raise InvalidForm(form) - return flask.jsonify(copr_dict) - -@apiv3_ns.route("/project/regenerate-repos//", methods=PUT) -@api_login_required -@editable_copr -def regenerate_repos(copr): - """ - This function will regenerate all repository metadata for a project. - """ - with db_session_scope(): - ActionsLogic.send_createrepo(copr, devel=False) + raise InvalidForm(form) + + return to_dict(fcopr) + + +@apiv3_projects_ns.route("/delete//") +class ProjectDelete(Resource): # pylint: disable=missing-class-docstring + parser = project_parser() + + @api_login_required + @apiv3_projects_ns.doc(fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(parser) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Project successfully deleted") + def delete(self, ownername, projectname): + """ + Delete Copr project + """ + copr = get_copr(ownername, projectname) + copr_dict = to_dict(copr) + form = forms.APICoprDeleteForm(meta={'csrf': False}) + + if form.validate_on_submit() and copr: + try: + ComplexLogic.delete_copr(copr) + except (ActionInProgressException, + InsufficientRightsException) as err: + db.session.rollback() + raise err - return flask.jsonify(to_dict(copr)) + db.session.commit() + else: + raise InvalidForm(form) + return copr_dict + + +@apiv3_projects_ns.route("/regenerate-repos//") +class RegenerateRepos(Resource): # pylint: disable=missing-class-docstring + parser = project_parser() + + @api_login_required + @get_editable_copr + @apiv3_projects_ns.doc(fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(parser) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "OK, reposirory metadata regenerated") + def post(self, copr): + """ + Regenerate all repository metadata for a Copr project + """ + with db_session_scope(): + ActionsLogic.send_createrepo(copr, devel=False) + + return to_dict(copr) | {"projectname": "passing vibes"} diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py index 28319a561..1df24ddd1 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py @@ -43,6 +43,11 @@ example="@copr", ) +fullname_field = String( + description="Full name of the project", + example="@copr/pull-requests", +) + projectname_field = String( description="Name of the project", example="copr-dev", @@ -286,6 +291,56 @@ example="DESC", ) +homepage_field = Url( + description="Homepage URL of Copr project", + example="https://github.com/fedora-copr", +) + +contact_field = String( + description="Contact email", + example="pretty_user@fancydomain.uwu", +) + +description_field = String( + description="Description of Copr project", +) + +instructions_field = String( + description="Instructions how to install and use Copr project", +) + +persistent_field = Boolean( + description="Build and project is immune against deletion", +) + +unlisted_on_hp_field = Boolean( + description="Don't list Copr project on home page", +) + +auto_prune_field = Boolean( + description="Automatically delete builds in this project", +) + +build_enable_net_field = Boolean( + description="Enable networking for the builds", +) + +appstream_field = Boolean( + description="Enable Appstream for this project", +) + +packit_forge_projects_allowed_field = String( + description="Whitespace separated list of forge projects that will be " + "allowed to build in the project via Packit", + example="github.com/fedora-copr/copr github.com/another/project", +) + +follow_fedora_branching_field = Boolean( + description="If chroots for the new branch should be auto-enabled and populated from " + "rawhide ones", +) + + pagination_schema = { "limit_field": limit_field, "offset_field": offset_field, @@ -410,6 +465,34 @@ package_model = api.model("Package", package_schema) +project_schema = { + "id": id_field, + "name": projectname_field, + "ownername": ownername_field, + "full_name": fullname_field, + "homepage": homepage_field, + "contact": contact_field, + "description": description_field, + "instructions": instructions_field, + "devel_mode": Boolean, + "persistent": persistent_field, + "unlisted_on_hp": unlisted_on_hp_field, + "auto_prune": auto_prune_field, + "chroot_repos": Raw, + "additional_repos": additional_repos_field, + "enable_net": build_enable_net_field, + "bootstrap": String, + "isolation": isolation_field, + "module_hotfixes": module_hotfixes_field, + "appstream": appstream_field, + "packit_forge_projects_allowed": packit_forge_projects_allowed_field, + "follow_fedora_branching": follow_fedora_branching_field, + "repo_priority": repo_priority_field, +} + +project_model = api.model("Project", project_schema) + + def clone(field): """ Return a copy of a field @@ -418,9 +501,13 @@ def clone(field): return field.__class__(**kwargs) -add_package_params = { +fullname_params = { "ownername": ownername_field.description, "projectname": projectname_field.description, +} + +add_package_params = { + **fullname_params, "package_name": packagename_field.description, "source_type_text": source_type_field.description, } @@ -434,6 +521,7 @@ def clone(field): "build_id": id_field.description, } + def to_arg_type(field): """ Take a field on the input, find out its type and convert it to a type that @@ -444,6 +532,8 @@ def to_arg_type(field): String: str, Boolean: boolean, List: list, + Url: str, + Raw: type, # similar to `Any` in typing } for key, value in types.items(): if isinstance(field, key): @@ -476,6 +566,28 @@ def merge_parsers(a, b): return parser +def schema2parser(schema, required_args=True, **arg_kwargs): + """ + Convert schema to parser. + """ + args = [] + for name, field_or_type in schema.items(): + if isinstance(field_or_type, type): + # general raw type like Boolean, convert to field + field = field_or_type() + else: + field = field_or_type + + args.append(field2arg(name, field, **arg_kwargs)) + + parser = RequestParser() + for arg in args: + arg.required = required_args + parser.add_argument(arg) + + return parser + + def get_package_parser(): # pylint: disable=missing-function-docstring parser = RequestParser() @@ -557,3 +669,17 @@ def project_chroot_parser(): arg.required = True parser.add_argument(arg) return parser + + +def get_project_parser(): + # pylint: disable=missing-function-docstring + parser = RequestParser() + parser.add_argument(field2arg("ownername", ownername_field, required=True)) + parser.add_argument(field2arg("projectname", projectname_field, required=True)) + + return parser + + +def project_parser(): + # pylint: disable=missing-function-docstring + return schema2parser(project_schema)