From ab18608625615eed4671345e252a83c3d31591fb 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 | 210 ++++-- .../coprs/views/apiv3_ns/apiv3_builds.py | 10 +- .../coprs/views/apiv3_ns/apiv3_packages.py | 49 +- .../views/apiv3_ns/apiv3_project_chroots.py | 30 +- .../coprs/views/apiv3_ns/apiv3_projects.py | 609 ++++++++++++------ .../coprs/views/apiv3_ns/schema.py | 559 ---------------- .../coprs/views/apiv3_ns/schema/__init__.py | 0 .../coprs/views/apiv3_ns/schema/docs.py | 36 ++ .../coprs/views/apiv3_ns/schema/fields.py | 438 +++++++++++++ .../coprs/views/apiv3_ns/schema/schemas.py | 519 +++++++++++++++ frontend/coprs_frontend/coprs/views/misc.py | 95 ++- 11 files changed, 1679 insertions(+), 876 deletions(-) delete mode 100644 frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py create mode 100644 frontend/coprs_frontend/coprs/views/apiv3_ns/schema/__init__.py create mode 100644 frontend/coprs_frontend/coprs/views/apiv3_ns/schema/docs.py create mode 100644 frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py create mode 100644 frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py index cbb6953fe..e14a68da2 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py @@ -5,16 +5,11 @@ 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, Namespace from coprs.exceptions import ( AccessRestricted, - ActionInProgressException, CoprHttpException, - InsufficientStorage, - ObjectNotFound, BadRequest, ) from coprs.logic.complex_logic import ComplexLogic @@ -51,48 +46,68 @@ def home(): # HTTP methods GET = ["GET"] POST = ["POST"] +# TODO: POST != PUT nor DELETE, we should use at least use these methods according +# conventions -> POST to create new element, PUT to update element, DELETE to delete +# https://www.ibm.com/docs/en/urbancode-release/6.1.1?topic=reference-rest-api-conventions +# fix python-copr firstly please, then put warning header to deprecated methods PUT = ["POST", "PUT"] DELETE = ["POST", "DELETE"] +def _convert_query_params(endpoint_method, params_to_not_look_for, **kwargs): + sig = inspect.signature(endpoint_method) + params = list(set(sig.parameters) - params_to_not_look_for) + for arg in params: + if arg not in flask.request.args: + # If parameter is present in the URL path, we can use its + # value instead of failing that it is missing in query + # parameters, e.g. let's have a view decorated with these + # two routes: + # @foo_ns.route("/foo/bar//") + # @foo_ns.route("/foo/bar") accepting ?build=X&chroot=Y + # @query_params() + # Then we need the following condition to get the first + # route working + if arg in flask.request.view_args: + continue + + # If parameter has a default value, it is not required + default_parameter_value = sig.parameters[arg].default + if default_parameter_value != sig.parameters[arg].empty: + kwargs[arg] = default_parameter_value + continue + + raise BadRequest("Missing argument {}".format(arg)) + + kwargs[arg] = flask.request.args.get(arg) + return kwargs + + def query_params(): + params_to_not_look_for = {"args", "kwargs"} + def query_params_decorator(f): @wraps(f) def query_params_wrapper(*args, **kwargs): - sig = inspect.signature(f) - params = [x for x in sig.parameters] - params = list(set(params) - {"args", "kwargs"}) - for arg in params: - if arg not in flask.request.args: - # If parameter is present in the URL path, we can use its - # value instead of failing that it is missing in query - # parameters, e.g. let's have a view decorated with these - # two routes: - # @foo_ns.route("/foo/bar//") - # @foo_ns.route("/foo/bar") accepting ?build=X&chroot=Y - # @query_params() - # Then we need the following condition to get the first - # route working - if arg in flask.request.view_args: - continue - - # If parameter has a default value, it is not required - if sig.parameters[arg].default == sig.parameters[arg].empty: - raise BadRequest("Missing argument {}".format(arg)) - kwargs[arg] = flask.request.args.get(arg) + kwargs = _convert_query_params(f, params_to_not_look_for, **kwargs) return f(*args, **kwargs) return query_params_wrapper return query_params_decorator +def _shared_pagination_wrapper(**kwargs): + form = PaginationForm(flask.request.args) + if not form.validate(): + raise CoprHttpException(form.errors) + kwargs.update(form.data) + return kwargs + + def pagination(): def pagination_decorator(f): @wraps(f) def pagination_wrapper(*args, **kwargs): - form = PaginationForm(flask.request.args) - if not form.validate(): - raise CoprHttpException(form.errors) - kwargs.update(form.data) + kwargs = _shared_pagination_wrapper(**kwargs) return f(*args, **kwargs) return pagination_wrapper return pagination_decorator @@ -232,19 +247,24 @@ def get(self): return objects[self.offset : limit] +def _check_if_user_can_edit_copr(ownername, projectname): + copr = get_copr(ownername, projectname) + if not flask.g.user.can_edit(copr): + raise AccessRestricted( + "User '{0}' can not see permissions for project '{1}' " \ + "(missing admin rights)".format( + flask.g.user.name, + '/'.join([ownername, projectname]) + ) + ) + return copr + + def editable_copr(f): @wraps(f) - def wrapper(ownername, projectname, **kwargs): - copr = get_copr(ownername, projectname) - if not flask.g.user.can_edit(copr): - raise AccessRestricted( - "User '{0}' can not see permissions for project '{1}' "\ - "(missing admin rights)".format( - flask.g.user.name, - '/'.join([ownername, projectname]) - ) - ) - return f(copr, **kwargs) + def wrapper(ownername, projectname): + copr = _check_if_user_can_edit_copr(ownername, projectname) + return f(copr) return wrapper @@ -374,3 +394,109 @@ def rename_fields_helper(input_dict, replace): for value in values: output.add(new_key, value) return output + + +# Flask-restx specific helpers/decorators - don't use them with regular Flask API! +# TODO: delete/unify decorators for regular Flask and Flask-restx API once migration +# is done + + +def query_to_parameters(endpoint_method): + """ + Decorator passing query parameters to http method parameters + + Returns: + Endpoint that has its query parameters can be used as parameters in http method + """ + params_to_not_look_for = {"self", "args", "kwargs"} + + @wraps(endpoint_method) + def convert_query_parameters_of_endpoint_method(self, *args, **kwargs): + kwargs = _convert_query_params(endpoint_method, params_to_not_look_for, **kwargs) + return endpoint_method(self, *args, **kwargs) + return convert_query_parameters_of_endpoint_method + + +def deprecated_route_method(ns: Namespace, msg): + """ + Decorator that display a deprecation warning in headers and docs. + + Usage: + class Endpoint(Resource): + ... + @deprecated_route_method(foo_ns, "Message e.g. what to use instead") + ... + def get(): + return {"scary": "BOO!"} + + Args: + ns: flask-restx Namespace + msg: Deprecation warning message. + """ + def decorate_endpoint_method(endpoint_method): + # render deprecation in API docs + ns.deprecated(endpoint_method) + + @wraps(endpoint_method) + def warn_user_in_headers(self, *args, **kwargs): + custom_header = {"Warning": f"This method is deprecated: {msg}"} + resp = endpoint_method(self, *args, **kwargs) + if not isinstance(resp, tuple): + # only resp body as dict was passed + return resp, custom_header + + for part_of_resp in resp[1:]: + if isinstance(part_of_resp, dict): + part_of_resp |= custom_header + return resp + + return resp + (custom_header,) + + return warn_user_in_headers + return decorate_endpoint_method + + +def deprecated_route_method_type(ns: Namespace, deprecated_method_type: str, use_instead: str): + """ + Calls deprecated_route decorator with specific message about deprecated method. + + Usage: + class Endpoint(Resource): + ... + @deprecated_route_method_type(foo_ns, "POST", "PUT") + ... + def get(): + return {"scary": "BOO!"} + + Args: + ns: flask-restx Namespace + deprecated_method_type: method enum e.g. POST + use_instead: method user should use instead + """ + def call_deprecated_endpoint_method(endpoint_method): + msg = f"Use {use_instead} method instead of {deprecated_method_type}" + return deprecated_route_method(ns, msg)(endpoint_method) + return call_deprecated_endpoint_method + + +def restx_editable_copr(endpoint_method): + """ + Raises an exception if user don't have permissions for editing Copr repo. + """ + @wraps(endpoint_method) + def editable_copr_getter(self, ownername, projectname): + copr = _check_if_user_can_edit_copr(ownername, projectname) + return endpoint_method(self, copr) + return editable_copr_getter + + +def restx_pagination(endpoint_method): + """ + Validates pagination arguments and converts pagination parameters from query to + kwargs. + """ + @wraps(endpoint_method) + def create_pagination(self, *args, **kwargs): + kwargs = _shared_pagination_wrapper(**kwargs) + return endpoint_method(self, *args, **kwargs) + return create_pagination 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 bca1c4601..e47e67e72 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py @@ -15,10 +15,8 @@ from coprs.exceptions import (BadRequest, AccessRestricted) from coprs.views.misc import api_login_required from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper -from coprs.views.apiv3_ns.schema import ( - build_model, - get_build_params, -) +from coprs.views.apiv3_ns.schema.schemas import build_model +from coprs.views.apiv3_ns.schema.docs import get_build_docs from coprs.logic.complex_logic import ComplexLogic from coprs.logic.builds_logic import BuildsLogic from coprs.logic.coprs_logic import CoprDirsLogic @@ -38,8 +36,6 @@ from .json2form import get_form_compatible_data - - apiv3_builds_ns = Namespace("build", description="Builds") api.add_namespace(apiv3_builds_ns) @@ -95,7 +91,7 @@ def render_build(build): @apiv3_builds_ns.route("/") class GetBuild(Resource): - @apiv3_builds_ns.doc(params=get_build_params) + @apiv3_builds_ns.doc(params=get_build_docs) @apiv3_builds_ns.marshal_with(build_model) def get(self, build_id): """ diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py index fbda9221b..672c5e67f 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_packages.py @@ -15,17 +15,16 @@ UnknownSourceTypeException, InvalidForm, ) -from coprs.views.misc import api_login_required +from coprs.views.misc import api_login_required, restx_api_login_required from coprs import db, models, forms, helpers -from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper -from coprs.views.apiv3_ns.schema import ( +from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper, query_to_parameters +from coprs.views.apiv3_ns.schema.schemas import ( package_model, - add_package_params, - edit_package_params, - get_package_parser, - add_package_parser, - edit_package_parser, + package_get_params, + package_add_input_model, + package_edit_input_model, ) +from coprs.views.apiv3_ns.schema.docs import add_package_docs, edit_package_docs from coprs.logic.packages_logic import PackagesLogic # @TODO if we need to do this on several places, we should figure a better way to do it @@ -110,25 +109,21 @@ def get_arg_to_bool(argument): @apiv3_packages_ns.route("/") class GetPackage(Resource): - parser = get_package_parser() - - @apiv3_packages_ns.expect(parser) + @query_to_parameters + @apiv3_packages_ns.doc(params=package_get_params) @apiv3_packages_ns.marshal_with(package_model) - def get(self): + def get(self, ownername, projectname, packagename, with_latest_build=False, + with_latest_succeeded_build=False): """ Get a package Get a single package from a Copr project. """ - args = self.parser.parse_args() - with_latest_build = args.with_latest_build - with_latest_succeeded_build = args.with_latest_succeeded_build - - copr = get_copr(args.ownername, args.projectname) + copr = get_copr(ownername, projectname) try: - package = PackagesLogic.get(copr.id, args.packagename)[0] + package = PackagesLogic.get(copr.id, packagename)[0] except IndexError as ex: msg = ("No package with name {name} in copr {copr}" - .format(name=args.packagename, copr=copr.name)) + .format(name=packagename, copr=copr.name)) raise ObjectNotFound(msg) from ex return to_dict(package, with_latest_build, with_latest_succeeded_build) @@ -171,11 +166,9 @@ def get_package_list(ownername, projectname, with_latest_build=False, @apiv3_packages_ns.route("/add////") class PackageAdd(Resource): - parser = add_package_parser() - - @api_login_required - @apiv3_packages_ns.doc(params=add_package_params) - @apiv3_packages_ns.expect(parser) + @restx_api_login_required + @apiv3_packages_ns.doc(params=add_package_docs) + @apiv3_packages_ns.expect(package_add_input_model) @apiv3_packages_ns.marshal_with(package_model) def post(self, ownername, projectname, package_name, source_type_text): """ @@ -195,11 +188,9 @@ def post(self, ownername, projectname, package_name, source_type_text): @apiv3_packages_ns.route("/edit////") @apiv3_packages_ns.route("/edit////") class PackageEdit(Resource): - parser = edit_package_parser() - - @api_login_required - @apiv3_packages_ns.doc(params=edit_package_params) - @apiv3_packages_ns.expect(parser) + @restx_api_login_required + @apiv3_packages_ns.doc(params=edit_package_docs) + @apiv3_packages_ns.expect(package_edit_input_model) @apiv3_packages_ns.marshal_with(package_model) def post(self, ownername, projectname, package_name, source_type_text=None): """ diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py index a62043f04..810d4e25c 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_project_chroots.py @@ -5,11 +5,11 @@ import flask from flask_restx import Namespace, Resource from coprs.views.misc import api_login_required -from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper -from coprs.views.apiv3_ns.schema import ( +from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper, query_to_parameters +from coprs.views.apiv3_ns.schema.schemas import ( project_chroot_model, project_chroot_build_config_model, - project_chroot_parser, + project_chroot_get_params, ) from coprs.logic.complex_logic import ComplexLogic, BuildConfigLogic from coprs.exceptions import ObjectNotFound, InvalidForm @@ -75,35 +75,31 @@ def rename_fields(input_dict): @apiv3_project_chroots_ns.route("/") class ProjectChroot(Resource): - parser = project_chroot_parser() - - @apiv3_project_chroots_ns.expect(parser) + @query_to_parameters + @apiv3_project_chroots_ns.doc(params=project_chroot_get_params) @apiv3_project_chroots_ns.marshal_with(project_chroot_model) - def get(self): + def get(self, ownername, projectname, chrootname): """ Get a project chroot Get settings for a single project chroot. """ - args = self.parser.parse_args() - copr = get_copr(args.ownername, args.projectname) - chroot = ComplexLogic.get_copr_chroot(copr, args.chrootname) + copr = get_copr(ownername, projectname) + chroot = ComplexLogic.get_copr_chroot(copr, chrootname) return to_dict(chroot) @apiv3_project_chroots_ns.route("/build-config") class BuildConfig(Resource): - parser = project_chroot_parser() - - @apiv3_project_chroots_ns.expect(parser) + @query_to_parameters + @apiv3_project_chroots_ns.doc(params=project_chroot_get_params) @apiv3_project_chroots_ns.marshal_with(project_chroot_build_config_model) - def get(self): + def get(self, ownername, projectname, chrootname): """ Get a build config Generate a build config based on a project chroot settings. """ - args = self.parser.parse_args() - copr = get_copr(args.ownername, args.projectname) - chroot = ComplexLogic.get_copr_chroot(copr, args.chrootname) + copr = get_copr(ownername, projectname) + chroot = ComplexLogic.get_copr_chroot(copr, chrootname) if not chroot: raise ObjectNotFound('Chroot not found.') return to_build_config_dict(chroot) 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 80fd1f919..01c1d3413 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py @@ -1,19 +1,53 @@ +# pylint: disable=missing-class-docstring + +from http import HTTPStatus + import flask -from coprs.views.apiv3_ns import (query_params, get_copr, pagination, Paginator, - GET, POST, PUT, DELETE, set_defaults) + +from flask_restx import Namespace, Resource + +from coprs.views.apiv3_ns import ( + get_copr, + restx_pagination, + Paginator, + set_defaults, + deprecated_route_method_type, + restx_editable_copr, +) 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.misc import restx_api_login_required +from coprs.views.apiv3_ns import rename_fields_helper, api, query_to_parameters +from coprs.views.apiv3_ns.schema.schemas import ( + project_model, + project_add_input_model, + project_edit_input_model, + project_fork_input_model, + project_delete_input_model, + fullname_params, + pagination_project_model, + ownername_params, + pagination_params, +) +from coprs.views.apiv3_ns.schema.docs import query_docs from coprs.logic.actions_logic import ActionsLogic from coprs.logic.coprs_logic import CoprsLogic, CoprChrootsLogic, MockChrootsLogic from coprs.logic.complex_logic import ComplexLogic from coprs.logic.users_logic import UsersLogic -from coprs.exceptions import (DuplicateException, NonAdminCannotCreatePersistentProject, - NonAdminCannotDisableAutoPrunning, ActionInProgressException, - InsufficientRightsException, BadRequest, ObjectNotFound, - InvalidForm) -from . import editable_copr +from coprs.exceptions import ( + DuplicateException, + NonAdminCannotCreatePersistentProject, + NonAdminCannotDisableAutoPrunning, + ActionInProgressException, + InsufficientRightsException, + BadRequest, + ObjectNotFound, + InvalidForm, +) + + +apiv3_projects_ns = Namespace("project", description="Projects") +api.add_namespace(apiv3_projects_ns) def to_dict(copr): @@ -40,6 +74,11 @@ def to_dict(copr): "packit_forge_projects_allowed": copr.packit_forge_projects_allowed_list, "follow_fedora_branching": copr.follow_fedora_branching, "repo_priority": copr.repo_priority, + # TODO: unify projectname and name or (good luck) force marshaling to work + # without it. Marshaling tries to create a docs page for the endpoint to + # HTML with argument names the same as they are defined in methods + # but we have this inconsistency between name - projectname + "projectname": copr.name, } @@ -78,201 +117,385 @@ 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_ns.route("/project/list", methods=GET) -@pagination() -@query_params() -def get_project_list(ownername=None, **kwargs): - query = CoprsLogic.get_multiple() - if ownername: - query = CoprsLogic.filter_by_ownername(query, ownername) - paginator = Paginator(query, models.Copr, **kwargs) - projects = paginator.map(to_dict) - return flask.jsonify(items=projects, meta=paginator.meta) - - -@apiv3_ns.route("/project/search", methods=GET) -@pagination() -@query_params() -# @TODO should the param be query or projectname? -def search_projects(query, **kwargs): - try: - search_query = CoprsLogic.get_multiple_fulltext(query) - paginator = Paginator(search_query, models.Copr, **kwargs) +@apiv3_projects_ns.route("/") +class Project(Resource): + @query_to_parameters + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "OK, Project data follows...") + @apiv3_projects_ns.response( + HTTPStatus.NOT_FOUND.value, "No such Copr project found in database" + ) + def get(self, ownername, projectname): + """ + Get a project + Get details for a single Copr project according to ownername and projectname. + """ + copr = get_copr(ownername, projectname) + return to_dict(copr) + + +@apiv3_projects_ns.route("/list") +class ProjectList(Resource): + @restx_pagination + @query_to_parameters + @apiv3_projects_ns.doc(params=ownername_params | pagination_params) + @apiv3_projects_ns.marshal_list_with(pagination_project_model) + @apiv3_projects_ns.response( + HTTPStatus.PARTIAL_CONTENT.value, HTTPStatus.PARTIAL_CONTENT.description + ) + def get(self, ownername=None, **kwargs): + """ + Get list of projects + Get details for multiple Copr projects according to ownername + """ + query = CoprsLogic.get_multiple() + if ownername: + query = CoprsLogic.filter_by_ownername(query, ownername) + paginator = Paginator(query, models.Copr, **kwargs) projects = paginator.map(to_dict) - except ValueError as ex: - raise BadRequest(str(ex)) - 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}) + return {"items": projects, "meta": paginator.meta} + + +@apiv3_projects_ns.route("/search") +class ProjectSearch(Resource): + @restx_pagination + @query_to_parameters + @apiv3_projects_ns.doc(params=query_docs) + @apiv3_projects_ns.marshal_list_with(pagination_project_model) + @apiv3_projects_ns.response( + HTTPStatus.PARTIAL_CONTENT.value, HTTPStatus.PARTIAL_CONTENT.description + ) + # TODO: should the param be query or projectname? + def get(self, query, **kwargs): + """ + Get list of projects + Get details for multiple Copr projects according to search query. + """ + try: + search_query = CoprsLogic.get_multiple_fulltext(query) + paginator = Paginator(search_query, models.Copr, **kwargs) + projects = paginator.map(to_dict) + except ValueError as ex: + raise BadRequest(str(ex)) from ex + return {"items": projects, "meta": paginator.meta} + + +@apiv3_projects_ns.route("/add/") +class ProjectAdd(Resource): + @restx_api_login_required + @apiv3_projects_ns.doc(params=ownername_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_add_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project created") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + def post(self, ownername): + """ + Create new Copr project + Create new Copr project for ownername with specified data inserted in form. + """ + 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 - 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)) + return to_dict(copr) + +@apiv3_projects_ns.route("/edit//") +class ProjectEdit(Resource): + @staticmethod + def _common(ownername, projectname): + copr = get_copr(ownername, projectname) + data = rename_fields(get_form_compatible_data(preserve=["chroots"])) + form = forms.CoprForm(data, meta={"csrf": False}) -@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}) + 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) - except (ActionInProgressException, - InsufficientRightsException) as err: + 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 err + raise ex + + return to_dict(copr) + + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_edit_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project successfully edited") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + def put(self, ownername, projectname): + """ + Edit Copr project + Edit existing Copr project for ownername/projectname in form. + """ + return self._common(ownername, projectname) + + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_edit_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project successfully edited") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + @deprecated_route_method_type(apiv3_projects_ns, "POST", "PUT") + def post(self, ownername, projectname): + """ + Edit Copr project + Edit existing Copr project for ownername/projectname in form. + """ + return self._common(ownername, projectname) + + +@apiv3_projects_ns.route("/fork//") +class ProjectFork(Resource): + @staticmethod + def _common(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}) + + 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) + + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_fork_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project is forking...") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + def post(self, ownername, projectname): + """ + Fork Copr project + Fork Copr project for specified ownername/projectname insto your namespace. + """ + return self._common(ownername, projectname) + + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_fork_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Copr project is forking...") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + @deprecated_route_method_type(apiv3_projects_ns, "PUT", "POST") + def put(self, ownername, projectname): + """ + Fork Copr project + Fork Copr project for specified ownername/projectname insto your namespace. + """ + return self._common(ownername, projectname) + + +@apiv3_projects_ns.route("/delete//") +class ProjectDelete(Resource): + @staticmethod + def _common(ownername, projectname): + 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 + + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_delete_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Project successfully deleted") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + def delete(self, ownername, projectname): + """ + Delete Copr project + Delete specified ownername/projectname Copr project forever. + """ + return self._common(ownername, projectname) + + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.expect(project_delete_input_model) + @apiv3_projects_ns.response(HTTPStatus.OK.value, "Project successfully deleted") + @apiv3_projects_ns.response( + HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.description + ) + @deprecated_route_method_type(apiv3_projects_ns, "POST", "DELETE") + def post(self, ownername, projectname): + """ + Delete Copr project + Delete specified ownername/projectname Copr project forever. + """ + return self._common(ownername, projectname) + + +@apiv3_projects_ns.route("/regenerate-repos//") +class RegenerateRepos(Resource): + @staticmethod + def _common(copr): + with db_session_scope(): + ActionsLogic.send_createrepo(copr, devel=False) + + return to_dict(copr) + + @restx_editable_copr + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.response( + HTTPStatus.OK.value, "OK, reposirory metadata regenerated" + ) + def put(self, copr): + """ + Regenerate all repository metadata for a Copr project + """ + return self._common(copr) + + @restx_editable_copr + @restx_api_login_required + @apiv3_projects_ns.doc(params=fullname_params) + @apiv3_projects_ns.marshal_with(project_model) + @apiv3_projects_ns.response( + HTTPStatus.OK.value, "OK, reposirory metadata regenerated" + ) + @deprecated_route_method_type(apiv3_projects_ns, "POST", "PUT") + def post(self, copr): + """ + Regenerate all repository metadata for a Copr project + """ + return self._common(copr) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py deleted file mode 100644 index 28319a561..000000000 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema.py +++ /dev/null @@ -1,559 +0,0 @@ -""" -Sometime in the future, we can maybe drop this whole file and generate schemas -from SQLAlchemy models: -https://github.com/python-restx/flask-restx/pull/493/files - -Things used for the output: - -- *_schema - describes our output schemas -- *_field - a schema is a dict of named fields -- *_model - basically a pair schema and its name - - -Things used for parsing the input: - -- *_parser - for documenting query parameters in URL and - parsing POST values in input JSON -- *_arg - a parser is composed from arguments -- *_params - for documenting path parameters in URL because parser - can't be properly used for them [1] - -[1] https://github.com/noirbizarre/flask-restplus/issues/146#issuecomment-212968591 -""" - - -from flask_restx.reqparse import Argument, RequestParser -from flask_restx.fields import String, List, Integer, Boolean, Nested, Url, Raw -from flask_restx.inputs import boolean -from coprs.views.apiv3_ns import api - - -id_field = Integer( - description="Numeric ID", - example=123, -) - -mock_chroot_field = String( - description="Mock chroot", - example="fedora-rawhide-x86_64", -) - -ownername_field = String( - description="User or group name", - example="@copr", -) - -projectname_field = String( - description="Name of the project", - example="copr-dev", -) - -project_dirname_field = String( - description="", - example="copr-dev:pr:123", -) - -packagename_field = String( - description="Name of the package", - example="copr-cli", -) - -comps_name_field = String( - description="Name of the comps.xml file", -) - -additional_repos_field = List( - String, - description="Additional repos to be used for builds in this chroot", -) - -additional_packages_field = List( - String, - description="Additional packages to be always present in minimal buildroot", -) - -additional_modules_field = List( - String, - description=("List of modules that will be enabled " - "or disabled in the given chroot"), - example=["module1:stream", "!module2:stream"], -) - -with_opts_field = List( - String, - description="Mock --with option", -) - -without_opts_field = List( - String, - description="Mock --without option", -) - -delete_after_days_field = Integer( - description="The project will be automatically deleted after this many days", - example=30, -) - -isolation_field = String( - description=("Mock isolation feature setup. Possible values " - "are 'default', 'simple', 'nspawn'."), - example="nspawn", -) - -repo_priority_field = Integer( - description="The priority value of this repository. Defaults to 99", - example=42, -) - -enable_net_field = Boolean( - description="Enable internet access during builds", -) - -source_type_field = String( - description=("See https://python-copr.readthedocs.io" - "/en/latest/client_v3/package_source_types.html"), - example="scm", -) - -scm_type_field = String( - default="Possible values are 'git', 'svn'", - example="git", -) - -source_build_method_field = String( - description="https://docs.pagure.org/copr.copr/user_documentation.html#scm", - example="tito", -) - -pypi_package_name_field = String( - description="Package name in the Python Package Index.", - example="copr", -) - -pypi_package_version_field = String( - description="PyPI package version", - example="1.128pre", -) - -# TODO We are copy-pasting descriptions from web UI to this file. This field -# is an ideal candidate for figuring out how to share the descriptions -pypi_spec_generator_field = String( - description=("Tool for generating specfile from a PyPI package. " - "The options are full-featured pyp2rpm with cross " - "distribution support, and pyp2spec that is being actively " - "developed and considered to be the future."), - example="pyp2spec", -) - -pypi_spec_template_field = String( - description=("Name of the spec template. " - "This option is limited to pyp2rpm spec generator."), - example="default", -) - -pypi_versions_field = List( - String, # We currently return string but should this be number? - description=("For what python versions to build. " - "This option is limited to pyp2rpm spec generator."), - example=["3", "2"], -) - -auto_rebuild_field = Boolean( - description="Auto-rebuild the package? (i.e. every commit or new tag)", -) - -clone_url_field = String( - description="URL to your Git or SVN repository", - example="https://github.com/fedora-copr/copr.git", -) - -committish_field = String( - description="Specific branch, tag, or commit that you want to build", - example="main", -) - -subdirectory_field = String( - description="Subdirectory where source files and .spec are located", - example="cli", -) - -spec_field = String( - description="Path to your .spec file under the specified subdirectory", - example="copr-cli.spec", -) - -chroots_field = List( - String, - description="List of chroot names", - example=["fedora-37-x86_64", "fedora-rawhide-x86_64"], -) - -submitted_on_field = Integer( - description="Timestamp when the build was submitted", - example=1677695304, -) - -started_on_field = Integer( - description="Timestamp when the build started", - example=1677695545, -) - -ended_on_field = Integer( - description="Timestamp when the build ended", - example=1677695963, -) - -is_background_field = Boolean( - description="The build is marked as a background job", -) - -submitter_field = String( - description="Username of the person who submitted this build", - example="frostyx", -) - -state_field = String( - description="", - example="succeeded", -) - -repo_url_field = Url( - description="See REPO OPTIONS in `man 5 dnf.conf`", - example="https://download.copr.fedorainfracloud.org/results/@copr/copr-dev/fedora-$releasever-$basearch/", -) - -max_builds_field = Integer( - description=("Keep only the specified number of the newest-by-id builds " - "(garbage collector is run daily)"), - example=10, -) - -source_package_url_field = String( - description="URL for downloading the SRPM package" -) - -source_package_version_field = String( - description="Package version", - example="1.105-1.git.53.319c6de", -) - -gem_name_field = String( - description="Gem name from RubyGems.org", - example="hello", -) - -custom_script_field = String( - description="Script code to produce a SRPM package", - example="#! /bin/sh -x", -) - -custom_builddeps_field = String( - description="URL to additional yum repos, which can be used during build.", - example="copr://@copr/copr", -) - -custom_resultdir_field = String( - description="Directory where SCRIPT generates sources", - example="./_build", -) - -custom_chroot_field = String( - description="What chroot to run the script in", - example="fedora-latest-x86_64", -) - -module_hotfixes_field = Boolean( - description="Allow non-module packages to override module packages", -) - -limit_field = Integer( - description="Limit", - example=20, -) - -offset_field = Integer( - description="Offset", - example=0, -) - -order_field = String( - description="Order by", - example="id", -) - -order_type_field = String( - description="Order type", - example="DESC", -) - -pagination_schema = { - "limit_field": limit_field, - "offset_field": offset_field, - "order_field": order_field, - "order_type_field": order_type_field, -} - -pagination_model = api.model("Pagination", pagination_schema) - -project_chroot_schema = { - "mock_chroot": mock_chroot_field, - "ownername": ownername_field, - "projectname": projectname_field, - "comps_name": comps_name_field, - "additional_repos": additional_repos_field, - "additional_packages": additional_packages_field, - "additional_modules": additional_modules_field, - "with_opts": with_opts_field, - "without_opts": without_opts_field, - "delete_after_days": delete_after_days_field, - "isolation": isolation_field, -} - -project_chroot_model = api.model("ProjectChroot", project_chroot_schema) - -repo_schema = { - "baseurl": String, - "id": String(example="copr_base"), - "name": String(example="Copr repository"), - "module_hotfixes": module_hotfixes_field, - "priority": repo_priority_field, -} - -repo_model = api.model("Repo", repo_schema) - -project_chroot_build_config_schema = { - "chroot": mock_chroot_field, - "repos": List(Nested(repo_model)), - "additional_repos": additional_repos_field, - "additional_packages": additional_packages_field, - "additional_modules": additional_modules_field, - "enable_net": enable_net_field, - "with_opts": with_opts_field, - "without_opts": without_opts_field, - "isolation": isolation_field, -} - -project_chroot_build_config_model = \ - api.model("ProjectChrootBuildConfig", project_chroot_build_config_schema) - -source_dict_scm_schema = { - "clone_url": clone_url_field, - "committish": committish_field, - "source_build_method": source_build_method_field, - "spec": spec_field, - "subdirectory": subdirectory_field, - "type": scm_type_field, -} - -source_dict_scm_model = api.model("SourceDictSCM", source_dict_scm_schema) - -source_dict_pypi_schema = { - "pypi_package_name": pypi_package_name_field, - "pypi_package_version": pypi_package_version_field, - "spec_generator": pypi_spec_generator_field, - "spec_template": pypi_spec_template_field, - "python_versions": pypi_versions_field, -} - -source_dict_pypi_model = api.model("SourceDictPyPI", source_dict_pypi_schema) - -source_package_schema = { - "name": packagename_field, - "url": source_package_url_field, - "version": source_package_version_field, -} - -source_package_model = api.model("SourcePackage", source_package_schema) - -build_schema = { - "chroots": chroots_field, - "ended_on": ended_on_field, - "id": id_field, - "is_background": is_background_field, - "ownername": ownername_field, - "project_dirname": project_dirname_field, - "projectname": projectname_field, - "repo_url": repo_url_field, - "source_package": Nested(source_package_model), - "started_on": started_on_field, - "state": state_field, - "submitted_on": submitted_on_field, - "submitter": submitter_field, -} - -build_model = api.model("Build", build_schema) - -package_builds_schema = { - "latest": Nested(build_model, allow_null=True), - "latest_succeeded": Nested(build_model, allow_null=True), -} - -package_builds_model = api.model("PackageBuilds", package_builds_schema) - -# TODO We use this schema for both GetPackage and PackageEdit. The `builds` -# field is returned for both but only in case of GetPackage it can contain -# results. How should we document this? -package_schema = { - "id": id_field, - "name": packagename_field, - "projectname": projectname_field, - "ownername": ownername_field, - "source_type": source_type_field, - # TODO Somehow a Polymorh should be used here for `source_dict_scm_model`, - # `source_dict_pypi_model`, etc. I don't know how, so leaving an - # undocumented value for the time being. - "source_dict": Raw, - "auto_rebuild": auto_rebuild_field, - "builds": Nested(package_builds_model), -} - -package_model = api.model("Package", package_schema) - - -def clone(field): - """ - Return a copy of a field - """ - kwargs = field.__dict__.copy() - return field.__class__(**kwargs) - - -add_package_params = { - "ownername": ownername_field.description, - "projectname": projectname_field.description, - "package_name": packagename_field.description, - "source_type_text": source_type_field.description, -} - -edit_package_params = { - **add_package_params, - "source_type_text": source_type_field.description, -} - -get_build_params = { - "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 - can be used with `RequestParser`. - """ - types = { - Integer: int, - String: str, - Boolean: boolean, - List: list, - } - for key, value in types.items(): - if isinstance(field, key): - return value - raise RuntimeError("Unknown field type: {0}" - .format(field.__class__.__name__)) - - -def field2arg(name, field, **kwargs): - """ - Take a field on the input and create an `Argument` for `RequestParser` - based on it. - """ - return Argument( - name, - type=to_arg_type(field), - help=field.description, - **kwargs, - ) - - -def merge_parsers(a, b): - """ - Take two `RequestParser` instances and create a new one, combining all of - their arguments. - """ - parser = RequestParser() - for arg in a.args + b.args: - parser.add_argument(arg) - return parser - - -def get_package_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)) - parser.add_argument(field2arg("packagename", packagename_field, required=True)) - - parser.add_argument( - "with_latest_build", type=boolean, required=False, default=False, - help=( - "The result will contain 'builds' dictionary with the latest " - "submitted build of this particular package within the project")) - - parser.add_argument( - "with_latest_succeeded_build", type=boolean, required=False, default=False, - help=( - "The result will contain 'builds' dictionary with the latest " - "successful build of this particular package within the project.")) - - return parser - - -def add_package_parser(): - # pylint: disable=missing-function-docstring - args = [ - # SCM - field2arg("clone_url", clone_url_field), - field2arg("committish", committish_field), - field2arg("subdirectory", subdirectory_field), - field2arg("spec", spec_field), - field2arg("scm_type", scm_type_field), - - # Rubygems - field2arg("gem_name", gem_name_field), - - # PyPI - field2arg("pypi_package_name", pypi_package_name_field), - field2arg("pypi_package_version", pypi_package_version_field), - field2arg("spec_generator", pypi_spec_generator_field), - field2arg("spec_template", pypi_spec_template_field), - field2arg("python_versions", pypi_versions_field), - - # Custom - field2arg("script", custom_script_field), - field2arg("builddeps", custom_builddeps_field), - field2arg("resultdir", custom_resultdir_field), - field2arg("chroot", custom_chroot_field), - - - field2arg("packagename", packagename_field), - field2arg("source_build_method", source_build_method_field), - field2arg("max_builds", max_builds_field), - field2arg("webhook_rebuild", auto_rebuild_field), - ] - parser = RequestParser() - for arg in args: - arg.location = "json" - parser.add_argument(arg) - return parser - - -def edit_package_parser(): - # pylint: disable=missing-function-docstring - parser = add_package_parser().copy() - for arg in parser.args: - arg.required = False - return parser - - -def project_chroot_parser(): - # pylint: disable=missing-function-docstring - parser = RequestParser() - args = [ - field2arg("ownername", ownername_field), - field2arg("projectname", projectname_field), - field2arg("chrootname", mock_chroot_field), - ] - for arg in args: - arg.required = True - parser.add_argument(arg) - return parser diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/__init__.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/docs.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/docs.py new file mode 100644 index 000000000..c00a5036f --- /dev/null +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/docs.py @@ -0,0 +1,36 @@ +""" +File for documentation for unicorn/small documentation in our API for Flask-restx. + Some are manually written for passing it directly to Swagger UI. +""" + +from coprs.views.apiv3_ns.schema import fields +from coprs.views.apiv3_ns.schema.fields import source_type, id_field + + +def _generate_docs(field_names, extra_fields=None): + result_dict = {} + for field_name in field_names: + result_dict[field_name] = getattr(fields, field_name).description + + if extra_fields is None: + return result_dict + + return result_dict | extra_fields + + +query_docs = { + "query": { + "description": "Search projects according query keyword.", + "example": "copr-cli", + } +} + +fullname_attrs = {"ownername", "projectname"} +fullname_docs = _generate_docs(fullname_attrs) + +src_type_dict = {"source_type_text": source_type.description} +add_package_docs = _generate_docs(fullname_attrs | {"package_name"}, src_type_dict) + +edit_package_docs = _generate_docs(fullname_docs, src_type_dict) + +get_build_docs = _generate_docs({}, {"build_id": id_field.description}) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py new file mode 100644 index 000000000..cd243cd10 --- /dev/null +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py @@ -0,0 +1,438 @@ +""" +Fields for Flask-restx used in schemas. + +Try to be consistent with field names and its corresponding names in API so + dynamic creation of models works. +""" + + +from flask_restx.fields import String, List, Integer, Boolean, Url, Raw + +# TODO: split these fields to some hierarchy e.g. using dataclasses or to some clusters + +# TODO: Use some shared constants for description - a lot of it is basically copied +# description from forms + +# TODO: some fields needs examples + +# TODO: this file is not perfect in documenting... missing enums, choices, etc. + + +id_field = Integer( + description="Numeric ID", + example=123, +) + +mock_chroot = String( + description="Mock chroot", + example="fedora-rawhide-x86_64", +) + +ownername = String( + description="User or group name", + example="@copr", +) + +full_name = String( + description="Full name of the project", + example="@copr/pull-requests", +) + +projectname = String( + description="Name of the project", + example="copr-dev", +) + +project_dirname = String( + description="", + example="copr-dev:pr:123", +) + +packagename = String( + description="Name of the package", + example="copr-cli", +) + +package_name = packagename + +comps_name = String( + description="Name of the comps.xml file", +) + +additional_repos = List( + String, + description="Additional repos to be used for builds in this chroot", +) + +additional_packages = List( + String, + description="Additional packages to be always present in minimal buildroot", +) + +additional_modules = List( + String, + description=( + "List of modules that will be enabled " "or disabled in the given chroot" + ), + example=["module1:stream", "!module2:stream"], +) + +with_opts = List( + String, + description="Mock --with option", +) + +without_opts = List( + String, + description="Mock --without option", +) + +delete_after_days = Integer( + description="The project will be automatically deleted after this many days", + example=30, +) + +isolation = String( + description=( + "Mock isolation feature setup. Possible values " + "are 'default', 'simple', 'nspawn'." + ), + example="nspawn", +) + +repo_priority = Integer( + description="The priority value of this repository. Defaults to 99", + example=42, +) + +enable_net = Boolean( + description="Enable internet access during builds", +) + +source_type = String( + description=( + "See https://python-copr.readthedocs.io" + "/en/latest/client_v3/package_source_types.html" + ), + example="scm", +) + +scm_type = String( + default="Possible values are 'git', 'svn'", + example="git", +) + +source_build_method = String( + description="https://docs.pagure.org/copr.copr/user_documentation.html#scm", + example="tito", +) + +pypi_package_name = String( + description="Package name in the Python Package Index.", + example="copr", +) + +pypi_package_version = String( + description="PyPI package version", + example="1.128pre", +) + +# TODO We are copy-pasting descriptions from web UI to this file. This field +# is an ideal candidate for figuring out how to share the descriptions +spec_generator = String( + description=( + "Tool for generating specfile from a PyPI package. " + "The options are full-featured pyp2rpm with cross " + "distribution support, and pyp2spec that is being actively " + "developed and considered to be the future." + ), + example="pyp2spec", +) + +spec_template = String( + description=( + "Name of the spec template. " + "This option is limited to pyp2rpm spec generator." + ), + example="default", +) + +python_versions = List( + String, # We currently return string but should this be number? + description=( + "For what python versions to build. " + "This option is limited to pyp2rpm spec generator." + ), + example=["3", "2"], +) + +auto_rebuild = Boolean( + description="Auto-rebuild the package? (i.e. every commit or new tag)", +) + +clone_url = String( + description="URL to your Git or SVN repository", + example="https://github.com/fedora-copr/copr.git", +) + +committish = String( + description="Specific branch, tag, or commit that you want to build", + example="main", +) + +subdirectory = String( + description="Subdirectory where source files and .spec are located", + example="cli", +) + +spec = String( + description="Path to your .spec file under the specified subdirectory", + example="copr-cli.spec", +) + +chroots = List( + String, + description="List of chroot names", + example=["fedora-37-x86_64", "fedora-rawhide-x86_64"], +) + +chroots.clone() + +submitted_on = Integer( + description="Timestamp when the build was submitted", + example=1677695304, +) + +started_on = Integer( + description="Timestamp when the build started", + example=1677695545, +) + +ended_on = Integer( + description="Timestamp when the build ended", + example=1677695963, +) + +is_background = Boolean( + description="The build is marked as a background job", +) + +submitter = String( + description="Username of the person who submitted this build", + example="frostyx", +) + +state = String( + description="", + example="succeeded", +) + +repo_url = Url( + description="See REPO OPTIONS in `man 5 dnf.conf`", + example="https://download.copr.fedorainfracloud.org/results/@copr/copr-dev/fedora-$releasever-$basearch/", +) + +max_builds = Integer( + description=( + "Keep only the specified number of the newest-by-id builds " + "(garbage collector is run daily)" + ), + example=10, +) + +source_package_url = String(description="URL for downloading the SRPM package") + +source_package_version = String( + description="Package version", + example="1.105-1.git.53.319c6de", +) + +gem_name = String( + description="Gem name from RubyGems.org", + example="hello", +) + +script = String( + description="Script code to produce a SRPM package", + example="#! /bin/sh -x", +) + +builddeps = String( + description="URL to additional yum repos, which can be used during build.", + example="copr://@copr/copr", +) + +resultdir = String( + description="Directory where SCRIPT generates sources", + example="./_build", +) + +chroot = String( + description="What chroot to run the script in", + example="fedora-latest-x86_64", +) + +module_hotfixes = Boolean( + description="Allow non-module packages to override module packages", +) + +limit = Integer( + description="Limit", + example=20, +) + +offset = Integer( + description="Offset", + example=0, +) + +order = String( + description="Order by", + example="id", +) + +order_type = String( + description="Order type", + example="DESC", +) + +homepage = Url( + description="Homepage URL of Copr project", + example="https://github.com/fedora-copr", +) + +contact = String( + description="Contact email", + example="pretty_user@fancydomain.uwu", +) + +description = String( + description="Description of Copr project", +) + +instructions = String( + description="Instructions how to install and use Copr project", +) + +persistent = Boolean( + description="Build and project is immune against deletion", +) + +unlisted_on_hp = Boolean( + description="Don't list Copr project on home page", +) + +auto_prune = Boolean( + description="Automatically delete builds in this project", +) + +build_enable_net = Boolean( + description="Enable networking for the builds", +) + +appstream = Boolean( + description="Enable Appstream for this project", +) + +packit_forge_projects_allowed = 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 = Boolean( + description=( + "If chroots for the new branch should be auto-enabled and populated from " + "rawhide ones" + ), +) + +with_latest_build = Boolean( + description=( + "The result will contain 'builds' dictionary with the latest " + "submitted build of this particular package within the project" + ), + default=False, +) + +with_latest_succeeded_build = Boolean( + description=( + "The result will contain 'builds' dictionary with the latest " + "successful build of this particular package within the project." + ), + default=False, +) + +fedora_review = Boolean( + description="Run fedora-review tool for packages in this project" +) + +runtime_dependencies = String( + description=( + "List of external repositories (== dependencies, specified as baseurls)" + "that will be automatically enabled together with this project repository." + ) +) + +bootstrap_image = String( + description=( + "Name of the container image to initialize" + "the bootstrap chroot from. This also implies bootstrap=image." + "This is a noop parameter and its value is ignored." + ) +) + +name = String(description="Name of the project", example="Copr repository") + +source_dict = Raw( + description="http://python-copr.readthedocs.io/en/latest/client_v3/package_source_types.html" +) + +devel_mode = Boolean(description="If createrepo should run automatically") + +bootstrap = String( + description=( + "Mock bootstrap feature setup. " + "Possible values are 'default', 'on', 'off', 'image'." + ) +) + +confirm = Boolean( + description=( + "If forking into a existing project, this needs to be set to True," + "to confirm that user is aware of that." + ) +) + +# TODO: these needs description + +chroot_repos = Raw() + +multilib = Boolean() + +verify = Boolean() + +priority = Integer() + +# TODO: specify those only in Repo schema? + +baseurl = Url() + +url = String() + +version = String() + +webhook_rebuild = Boolean() + + +def clone(field): + """ + Return a copy of a field + """ + if hasattr(field, "clone") and callable(getattr(field, "clone")): + return field.clone() + + kwargs = field.__dict__.copy() + return field.__class__(**kwargs) diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py new file mode 100644 index 000000000..be43ee4da --- /dev/null +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py @@ -0,0 +1,519 @@ +# pylint: disable=missing-class-docstring, too-many-instance-attributes +# pylint: disable=unused-private-member + +""" +File for schemas, models and data validation for our API + +Sometime in the future, we can maybe drop some schemas and/or their generation and +generate them from SQLAlchemy models: +https://github.com/python-restx/flask-restx/pull/493/files + +Things used for the input/output: + - *_schema - describes our output schemas + - *_model - basically a pair schema and its name +""" + + +# dataclasses are written that way we can easily switch to marshmallow/pydantic +# as flask-restx docs suggests if needed +# look to https://github.com/fedora-copr/copr/issues/3031 for more + + +# TODO: in case we will use marshmallow/pydantic, we should share these schemas +# somewhere (in copr common?) - CLI, frontend and backend (and maybe something else) +# shares these data with each other + + +from dataclasses import dataclass, fields, asdict, MISSING +from functools import wraps +from typing import Any + +from flask_restx.fields import String, List, Integer, Boolean, Nested, Url, Raw + +from coprs.views.apiv3_ns import api +from coprs.views.apiv3_ns.schema import fields as schema_fields +from coprs.views.apiv3_ns.schema.fields import scm_type, mock_chroot, additional_repos, clone + + +@dataclass +class Schema: + """ + Creates schemas/models for marshalling in flask-restx (and for data validation + in future?). Fields are automatically taken from fields.py file if the name + matches in Schema dataclass attribute and fields.py attribute, otherwise + you have to specify the attribute value directly or map the naming + in `unicorn_fields`. + + Usage: + class SomeSchema(Schema): + # if `some_attribute` is present in `fields.py` its value is used + some_attribute: String + + # if e.g. attribute in `fields.py` has name `weird_attribute` and for some + # reason you want to use `in_unicorn_map` here but still use the value + # specified in `fields.py`, add into `unicorn_fields` map this: + # "in_unicorn_map": "weird_attribute" + in_unicorn_map: String + + # if `not_in_fields` is not in `fields.py` you have to specify its value + not_in_fields: String = String(some definition ...) + """ + + @classmethod + def schema_attrs_from_fields(cls) -> dict[str, Any]: + """ + Get schema attributes for schema class according to its defined attributes. + Attributes are taken from field file and the names should match. + + Returns: + Schema for schema class + """ + result_schema = {} + for attr in fields(cls): + if attr.default is MISSING: + result_schema[attr.name] = clone(getattr(schema_fields, attr.name)) + else: + result_schema[attr.name] = attr.default + + return result_schema + + @staticmethod + def _convert_schema_class_dict_to_schema(d: dict) -> dict: + # if in fields.py file is attribute that has different name + # than model, add it to `unicorn_fields` like + # "field_name_in_fields.py": "what_you_want_to_name_it" + unicorn_fields = { + "id_field": "id", + } + # pylint: disable-next=consider-using-dict-items + for field_to_rename in unicorn_fields: + if field_to_rename in d: + d[unicorn_fields[field_to_rename]] = d[field_to_rename] + d.pop(field_to_rename) + + keys_to_delete = [] + for key, value in d.items(): + if key.startswith("_") or not isinstance(value, Raw): + keys_to_delete.append(key) + + for key_to_delete in keys_to_delete: + d.pop(key_to_delete) + + return d + + @classmethod + def get_cls(cls): + """ + Get instance of schema class. + """ + schema_dict = cls.schema_attrs_from_fields() + return cls(**schema_dict) + + def public_fields(self): + """ + Get all fields, private fields excluded + """ + result = [] + for field in fields(self): + if not field.name.startswith("_"): + result.append(field) + + return result + + def schema(self): + """ + Get schema dictionary with properly named key values (applies `unicorn_fields`). + Returns dynamic print of dataclass in dictionary. + """ + return self._convert_schema_class_dict_to_schema(asdict(self)) + + def model(self): + """ + Get Flask-restx model for the schema class. + """ + return api.model(self.__class__.__name__, self.schema()) + + +class InputSchema(Schema): + """ + Creates input schemas for flask-restx (and for data validation in future?). + """ + + @property + def required_attrs(self) -> list: + """ + Specify required attributes in model in these methods if needed. + + Specify this property in subclass with required attributes. If no attribute + is required, then don;t specify this property in subclass. + + In case every attribute is required in input schema, you have two options: + A): + Specify every attribute in required_attrs property. + B): + Define `__all_required` attribute in input schema and set it to `True`. + """ + return [] + + def _do_require_attrs(self): + change_this_args_as_required = self.required_attrs + if getattr(self, f"_{self.__class__.__name__}__all_required", False): + change_this_args_as_required = [ + getattr(self, field.name) for field in self.public_fields() + ] + + for field in change_this_args_as_required: + field.required = True + + def input_model(self): + """ + Returns an input model (input to @ns.expect()) with properly set required + parameters. + """ + self._do_require_attrs() + return api.model(self.__class__.__name__, self.schema()) + + +@dataclass +class ParamsSchema(InputSchema): + """ + Creates argument documentation for api_foo_ns.docs decorator that passes + documentation directly to Swagger UI. + + Do not use Argument class from flask-restx for generating documentation, it will + be deprecated with parsers. + """ + + def params_schema(self) -> dict: + """ + Returns parameters documentation that expands or overwrites default parameter + documentation taken from api_foo_ns.route. Documentation is a dictionary + in specific structure to match Swagger UI schema. + """ + super()._do_require_attrs() + schema = {} + for field in self.public_fields(): + attr = getattr(self, field.name) + schema[field.name] = attr.schema() + + keys_to_delete = [] + for key, val in schema[field.name].items(): + if val is None: + keys_to_delete.append(key) + + for key in keys_to_delete: + schema[field.name].pop(key) + + if attr.required: + schema[field.name] |= {"required": True} + + return schema + + +@dataclass +class PaginationMeta(ParamsSchema): + limit: Integer + offset: Integer + order: String + order_type: String + + +_pagination_meta_model = PaginationMeta.get_cls().model() + + +# checks if `items` in Pagination schema are defined (don't have None value) +def _check_if_items_are_defined(method): + @wraps(method) + def check_items(self, *args, **kwargs): + if getattr(self, "items") is None: + raise KeyError( + "No items are defined in Pagination. Perhaps you forgot to" + " specify it when creating Pagination instance?" + ) + return method(self, *args, **kwargs) + + return check_items + + +@dataclass +class Pagination(Schema): + """ + Pagination items can be basically anything (any schema) so specify a model to + `items` like this: build_pagination_model = Pagination(items=build_model).model() + """ + + items: Any = None + meta: Nested = Nested(_pagination_meta_model) + + @_check_if_items_are_defined + def model(self): + return super().model() + + +@dataclass +class _ProjectChrootFields: + additional_repos: List + additional_packages: List + additional_modules: List + with_opts: List + without_opts: List + isolation: String + + +@dataclass +class ProjectChroot(_ProjectChrootFields, Schema): + mock_chroot: String + ownername: String + projectname: String + comps_name: String + delete_after_days: Integer + + +@dataclass +class ProjectChrootGet(ParamsSchema): + ownername: String + projectname: String + chrootname: String = mock_chroot + + __all_required: bool = True + + +@dataclass +class Repo(Schema): + baseurl: Url + module_hotfixes: Boolean + priority: Integer + id_field: String = String(example="copr_base") + name: String = String(example="Copr repository") + + +_repo_model = Repo.get_cls().model() + + +@dataclass +class ProjectChrootBuildConfig(_ProjectChrootFields, Schema): + chroot: String + enable_net: Boolean + repos: List = List(Nested(_repo_model)) + + +@dataclass +class _SourceDictScmFields: + clone_url: String + committish: String + spec: String + subdirectory: String + + +@dataclass +class SourceDictScm(_SourceDictScmFields, Schema): + source_build_method: String + type: String = scm_type + + +@dataclass +class SourceDictPyPI(Schema): + pypi_package_name: String + pypi_package_version: String + spec_generator: String + spec_template: String + python_versions: List + + +@dataclass +class SourcePackage(Schema): + name: String + url: String + version: String + + +_source_package_model = SourcePackage.get_cls().model() + + +@dataclass +class Build(Schema): + chroots: List + ended_on: Integer + id_field: Integer + is_background: Boolean + ownername: String + project_dirname: String + projectname: String + repo_url: Url + started_on: Integer + state: String + submitted_on: Integer + submitter: String + source_package: Nested = Nested(_source_package_model) + + +_build_model = Build.get_cls().model() + + +@dataclass +class PackageBuilds(Schema): + latest: Nested = Nested(_build_model, allow_null=True) + latest_succeeded: Nested = Nested(_build_model, allow_null=True) + + +_package_builds_model = PackageBuilds().model() + + +@dataclass +class Package(Schema): + id_field: Integer + name: String + ownername: String + projectname: String + source_type: String + source_dict: Raw + auto_rebuild: Boolean + builds: Nested = Nested(_package_builds_model) + + +@dataclass +class PackageGet(ParamsSchema): + ownername: String + projectname: String + packagename: String + with_latest_build: Boolean + with_latest_succeeded_build: Boolean + + @property + def required_attrs(self) -> list: + return [self.ownername, self.projectname, self.packagename] + + +@dataclass +class PackageAdd(_SourceDictScmFields, SourceDictPyPI, InputSchema): + # rest of SCM + scm_type: String + + # Rubygems + gem_name: String + + # Custom + script: String + builddeps: String + resultdir: String + chroot: String + + packagename: String + source_build_method: String + max_builds: Integer + webhook_rebuild: Boolean + + +@dataclass +class _ProjectFields: + homepage: Url + contact: String + description: String + instructions: String + devel_mode: Boolean + unlisted_on_hp: Boolean + auto_prune: Boolean + enable_net: Boolean + bootstrap: String + isolation: String + module_hotfixes: Boolean + appstream: Boolean + packit_forge_projects_allowed: String + follow_fedora_branching: Boolean + repo_priority: Integer + + +@dataclass +class _ProjectGetAddFields: + name: String + persistent: Boolean + additional_repos: List + + +@dataclass +class Project(_ProjectFields, _ProjectGetAddFields, Schema): + id_field: Integer + ownername: String + full_name: String + chroot_repos: Raw + + +@dataclass +class _ProjectAddEditFields: + chroots: List + bootstrap_image: String + multilib: Boolean + fedora_review: Boolean + runtime_dependencies: String + + +@dataclass +class ProjectAdd( + _ProjectFields, _ProjectGetAddFields, _ProjectAddEditFields, InputSchema +): + ... + + +@dataclass +class ProjectEdit(_ProjectFields, _ProjectAddEditFields, InputSchema): + # TODO: fix inconsistency - additional_repos <-> repos + repos: String = additional_repos + + +@dataclass +class ProjectFork(InputSchema): + name: String + ownername: String + confirm: Boolean + + +@dataclass +class ProjectDelete(InputSchema): + verify: Boolean + + +@dataclass +class FullnameSchema(ParamsSchema): + ownername: String + projectname: String + + __all_required: bool = True + + +@dataclass +class OwnernameSchema(ParamsSchema): + ownername: String + + +# OUTPUT MODELS +project_chroot_model = ProjectChroot.get_cls().model() +project_chroot_build_config_model = ProjectChrootBuildConfig.get_cls().model() +source_dict_scm_model = SourceDictScm.get_cls().model() +source_dict_pypi_model = SourceDictPyPI.get_cls().model() +package_model = Package.get_cls().model() +project_model = Project.get_cls().model() + +pagination_project_model = Pagination(items=List(Nested(project_model))).model() + +source_package_model = _source_package_model +build_model = _build_model +package_builds_model = _package_builds_model +repo_model = _repo_model + + +# INPUT MODELS +package_add_input_model = PackageAdd.get_cls().input_model() +package_edit_input_model = package_add_input_model + +project_add_input_model = ProjectAdd.get_cls().input_model() +project_edit_input_model = ProjectEdit.get_cls().input_model() +project_fork_input_model = ProjectFork.get_cls().input_model() +project_delete_input_model = ProjectDelete.get_cls().input_model() + + +# PARAMETER SCHEMAS +package_get_params = PackageGet.get_cls().params_schema() +project_chroot_get_params = ProjectChrootGet.get_cls().params_schema() +fullname_params = FullnameSchema.get_cls().params_schema() +ownername_params = OwnernameSchema.get_cls().params_schema() +pagination_params = PaginationMeta.get_cls().params_schema() diff --git a/frontend/coprs_frontend/coprs/views/misc.py b/frontend/coprs_frontend/coprs/views/misc.py index 8f6db88f7..dfdb65a71 100644 --- a/frontend/coprs_frontend/coprs/views/misc.py +++ b/frontend/coprs_frontend/coprs/views/misc.py @@ -2,6 +2,8 @@ import datetime import functools from functools import wraps +from typing import Any + import flask from flask import send_file @@ -14,7 +16,7 @@ from coprs import oid from coprs.logic.complex_logic import ComplexLogic from coprs.logic.users_logic import UsersLogic -from coprs.exceptions import ObjectNotFound +from coprs.exceptions import ObjectNotFound, BadRequest from coprs.measure import checkpoint_start from coprs.auth import FedoraAccounts, UserAuth, OpenIDConnect from coprs.oidc import oidc_enabled @@ -156,41 +158,49 @@ def logout(): return UserAuth.logout() +def _shared_api_login_required_wrapper(): + token = None + api_login = None + if "Authorization" in flask.request.headers: + base64string = flask.request.headers["Authorization"] + base64string = base64string.split()[1].strip() + userstring = base64.b64decode(base64string) + (api_login, token) = userstring.decode("utf-8").split(":") + token_auth = False + if token and api_login: + user = UsersLogic.get_by_api_login(api_login).first() + if (user and user.api_token == token and + user.api_token_expiration >= datetime.date.today()): + token_auth = True + flask.g.user = user + if not token_auth: + url = 'https://' + app.config["PUBLIC_COPR_HOSTNAME"] + url = helpers.fix_protocol_for_frontend(url) + + msg = "Attempting to use invalid or expired API login '%s'" + app.logger.info(msg, api_login) + + output = { + "output": "notok", + "error": "Login invalid/expired. Please visit {0}/api to get or renew your API token.".format(url), + } + jsonout = flask.jsonify(output) + jsonout.status_code = 401 + return jsonout + return None + + def api_login_required(f): @functools.wraps(f) def decorated_function(*args, **kwargs): - token = None - api_login = None # flask.g.user can be already set in case a user is using gssapi auth, # in that case before_request was called and the user is known. if flask.g.user is not None: return f(*args, **kwargs) - if "Authorization" in flask.request.headers: - base64string = flask.request.headers["Authorization"] - base64string = base64string.split()[1].strip() - userstring = base64.b64decode(base64string) - (api_login, token) = userstring.decode("utf-8").split(":") - token_auth = False - if token and api_login: - user = UsersLogic.get_by_api_login(api_login).first() - if (user and user.api_token == token and - user.api_token_expiration >= datetime.date.today()): - token_auth = True - flask.g.user = user - if not token_auth: - url = 'https://' + app.config["PUBLIC_COPR_HOSTNAME"] - url = helpers.fix_protocol_for_frontend(url) - - msg = "Attempting to use invalid or expired API login '%s'" - app.logger.info(msg, api_login) - - output = { - "output": "notok", - "error": "Login invalid/expired. Please visit {0}/api to get or renew your API token.".format(url), - } - jsonout = flask.jsonify(output) - jsonout.status_code = 401 - return jsonout + retval = _shared_api_login_required_wrapper() + if retval is not None: + return retval + return f(*args, **kwargs) return decorated_function @@ -308,3 +318,30 @@ def wrapper(*args, **kwargs): raise ObjectNotFound("Invalid pagination format") from err return f(*args, page=page, **kwargs) return wrapper + + +# Flask-restx specific helpers/decorators - don't use them with regular Flask API! +# TODO: delete/unify decorators for regular Flask and Flask-restx API once migration +# is done + + +def restx_api_login_required(endpoint_method): + """ + Checks whether API login is required for an endpoint which is decorated + by this decorator. + + Returns: + notok API response if API login failed + """ + @wraps(endpoint_method) + def check_if_api_login_is_required(self, *args, **kwargs): + # flask.g.user can be already set in case a user is using gssapi auth, + # in that case before_request was called and the user is known. + if flask.g.user is not None: + return endpoint_method(self, *args, **kwargs) + retval = _shared_api_login_required_wrapper() + if retval is not None: + return retval + + return endpoint_method(self, *args, **kwargs) + return check_if_api_login_is_required