diff --git a/frontend/coprs_frontend/coprs/constants.py b/frontend/coprs_frontend/coprs/constants.py
index 85166a53f..e92dbebc0 100644
--- a/frontend/coprs_frontend/coprs/constants.py
+++ b/frontend/coprs_frontend/coprs/constants.py
@@ -1,8 +1,56 @@
"""
File which contains only constants. Nothing else.
"""
-
+from collections import namedtuple
+from enum import Enum
+from typing import Any
BANNER_LOCATION = "/var/lib/copr/data/banner-include.html"
DEFAULT_COPR_REPO_PRIORITY = 99
+
+
+CommonAttribute = namedtuple(
+ "CommonAttribute", ["description", "default"], defaults=("", None)
+)
+
+
+# just shortcut
+c = CommonAttribute # pylint: disable=invalid-name
+
+
+# Common descriptions for forms, fields, etc.
+class CommonDescriptions(Enum):
+ """
+ Enumerator for common descriptions and their default value between forms,
+ fields, etc.
+ """
+ ADDITIONAL_PACKAGES = c(
+ "Additional packages to be always present in minimal buildroot"
+ )
+ MOCK_CHROOT = c("Mock chroot", "fedora-latest-x86_64")
+ ADDITIONAL_REPOS = c("Additional repos to be used for builds in this chroot")
+ ENABLE_NET = c("Enable internet access during builds")
+ PYPI_PACKAGE_NAME = c("Package name in the Python Package Index")
+ PYPI_PACKAGE_VERSION = c("PyPI package version")
+ SPEC_GENERATOR = c(
+ "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."
+ )
+ AUTO_REBUILD = c("Auto-rebuild the package? (i.e. every commit or new tag)")
+
+ @property
+ def description(self) -> str:
+ """
+ Get description of Enum member
+ """
+ return self.value.description
+
+ @property
+ def default(self) -> Any:
+ """
+ Fet default value of Enum member
+ """
+ return self.value.default
diff --git a/frontend/coprs_frontend/coprs/forms.py b/frontend/coprs_frontend/coprs/forms.py
index 9b96bd45f..49e33971f 100644
--- a/frontend/coprs_frontend/coprs/forms.py
+++ b/frontend/coprs_frontend/coprs/forms.py
@@ -18,6 +18,7 @@
from coprs import exceptions
from coprs import helpers
from coprs import models
+from coprs.constants import CommonDescriptions
from coprs.logic.coprs_logic import CoprsLogic, MockChrootsLogic
from coprs.logic.users_logic import UsersLogic
from coprs.logic.dist_git_logic import DistGitLogic
@@ -625,11 +626,11 @@ class CoprForm(BaseForm):
# Deprecated, use `enable_net` instead
build_enable_net = wtforms.BooleanField(
- "Enable internet access during builds",
+ CommonDescriptions.ENABLE_NET.description,
default=False, false_values=FALSE_VALUES)
enable_net = wtforms.BooleanField(
- "Enable internet access during builds",
+ CommonDescriptions.ENABLE_NET.description,
default=False, false_values=FALSE_VALUES)
module_hotfixes = wtforms.BooleanField(
@@ -1057,9 +1058,9 @@ class PackageFormCustom(BasePackageForm):
filters=[StringListFilter()])
chroot = wtforms.SelectField(
- 'Mock chroot',
+ CommonDescriptions.MOCK_CHROOT.description,
choices=[],
- default='fedora-latest-x86_64',
+ default=CommonDescriptions.MOCK_CHROOT.default,
)
resultdir = wtforms.StringField(
@@ -1624,8 +1625,8 @@ class F(BaseForm):
class ModifyChrootForm(ChrootForm):
- buildroot_pkgs = wtforms.StringField('Additional packages to be always present in minimal buildroot')
- repos = wtforms.TextAreaField('Additional repos to be used for builds in chroot',
+ buildroot_pkgs = wtforms.StringField(CommonDescriptions.ADDITIONAL_PACKAGES.description)
+ repos = wtforms.TextAreaField(CommonDescriptions.ADDITIONAL_REPOS.description,
validators=[UrlRepoListValidator(),
wtforms.validators.Optional()],
filters=[StringListFilter()])
diff --git a/frontend/coprs_frontend/coprs/helpers.py b/frontend/coprs_frontend/coprs/helpers.py
index 8ed0e1a98..a854848a7 100644
--- a/frontend/coprs_frontend/coprs/helpers.py
+++ b/frontend/coprs_frontend/coprs/helpers.py
@@ -933,3 +933,23 @@ def generate_repo_id_and_name_ext(dependent, url, dep_idx):
generate_repo_name(url),
)
return repo_id, name
+
+
+def multiple_get(dictionary: dict, *keys) -> list:
+ """
+ Get multiple values from dictionary.
+ Args:
+ dictionary: Any dictionary
+ *keys: list of keys to obtain from dictionary
+ Returns:
+ *keys values in the same order as keys were given.
+ """
+ empty = "__empty_content"
+ result = []
+ for key in keys:
+ content = dictionary.get(key, empty)
+ if content == empty:
+ raise KeyError(f"Key missing: {key}")
+
+ result.append(content)
+ return result
diff --git a/frontend/coprs_frontend/coprs/templates/coprs/detail/_builds_forms.html b/frontend/coprs_frontend/coprs/templates/coprs/detail/_builds_forms.html
index 94ae5a744..72df1a2b6 100644
--- a/frontend/coprs_frontend/coprs/templates/coprs/detail/_builds_forms.html
+++ b/frontend/coprs_frontend/coprs/templates/coprs/detail/_builds_forms.html
@@ -149,7 +149,7 @@
{{ counter('instructions') }}. Select chroots and other
{% endmacro %}
-{% macro copr_build_form_pypi(form, view, copr) %}
+{% macro copr_build_form_pypi(form, view, copr, common_descriptions) %}
{{ copr_build_form_begin(form, view, copr) }}
{{ source_description(
@@ -161,15 +161,12 @@ {{ counter('instructions') }}. Select chroots and other
)
}}
- {{ render_field(form.pypi_package_name, placeholder="Package name in the Python Package Index.") }}
+ {{ render_field(form.pypi_package_name, placeholder="{{ common_descriptions.PYPI_PACKAGE_NAME.description }}.") }}
{{ render_field(form.pypi_package_version, placeholder="Optional - Version of the package PyPI") }}
{{ render_field(
form.spec_generator,
- info="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."
+ info="{{ common_descriptions.SPEC_GENERATOR.description }}"
)
}}
diff --git a/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_forms.html b/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_forms.html
index 59d8a3200..8d681f3ff 100644
--- a/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_forms.html
+++ b/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_forms.html
@@ -37,14 +37,14 @@ {{ counter('instructions') }}. Provide the source
{% endmacro %}
-{% macro render_webhook_rebuild(form) %}
+{% macro render_webhook_rebuild(form, common_descriptions) %}
@@ -88,7 +88,7 @@ {{ counter('instructions') }}. Generic package setup
{% endmacro %}
-{% macro copr_package_form_custom(form, view, copr, package) %}
+{% macro copr_package_form_custom(form, view, copr, package, common_descriptions) %}
{{ copr_package_form_begin(form, view, copr, package) }}
{{ copr_method_form_fileds_custom(form) }}
{{ render_generic_pkg_form(form) }}
- {{ render_webhook_rebuild(form) }}
+ {{ render_webhook_rebuild(form, common_descriptions) }}
{{ copr_package_form_end(form, package, 'custom') }}
{% endmacro %}
@@ -164,7 +164,7 @@
{% endmacro %}
-{% macro copr_package_form_scm(form, view, copr, package) %}
+{% macro copr_package_form_scm(form, view, copr, package, common_descriptions) %}
{{ copr_package_form_begin(form, view, copr, package) }}
{{ render_field(form.scm_type) }}
@@ -175,7 +175,7 @@
{{ render_srpm_build_method_box(form) }}
{{ render_generic_pkg_form(form) }}
- {{ render_webhook_rebuild(form) }}
+ {{ render_webhook_rebuild(form, common_descriptions) }}
{{ copr_package_form_end(form, package, 'mock_scm') }}
{% endmacro %}
diff --git a/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_helpers.html b/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_helpers.html
index 82adcee88..a6284cd9a 100644
--- a/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_helpers.html
+++ b/frontend/coprs_frontend/coprs/templates/coprs/detail/_package_helpers.html
@@ -41,7 +41,7 @@
{{ copr_package_form_rubygems(form_rubygems, view, copr, package) }}
{% elif source_type_text == "custom" %}
- {{ copr_package_form_custom(form_custom, view, copr, package) }}
+ {{ copr_package_form_custom(form_custom, view, copr, package, common_descriptions) }}
{% else %}
Wrong source type
diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py
index cbb6953fe..98ce64089 100644
--- a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py
+++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py
@@ -1,20 +1,16 @@
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, Namespace
from coprs.exceptions import (
AccessRestricted,
- ActionInProgressException,
CoprHttpException,
- InsufficientStorage,
- ObjectNotFound,
BadRequest,
)
from coprs.logic.complex_logic import ComplexLogic
@@ -51,48 +47,67 @@ 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
PUT = ["POST", "PUT"]
DELETE = ["POST", "DELETE"]
+def _convert_path_params_to_query(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_path_params_to_query(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 decorator - don't use them with regular Flask API!
+# TODO: delete/unify decorators for regular Flask and Flask-restx API once migration
+# is done
+
+
+def path_to_query(endpoint_method):
+ """
+ Decorator converting path parameters to query parameters
+
+ Returns:
+ Endpoint that has its path parameters converted as query parameters.
+ """
+ params_to_not_look_for = {"self", "args", "kwargs"}
+
+ @wraps(endpoint_method)
+ def convert_path_parameters_of_endpoint_method(self, *args, **kwargs):
+ kwargs = _convert_path_params_to_query(endpoint_method, params_to_not_look_for, **kwargs)
+ return endpoint_method(self, *args, **kwargs)
+ return convert_path_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("POST", "PUT")
+ ...
+ 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("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..0d635d243 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.schema.schemas import (
package_model,
- add_package_params,
- edit_package_params,
- get_package_parser,
- add_package_parser,
- edit_package_parser,
+ package_get_input_model,
+ 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,9 +109,7 @@ def get_arg_to_bool(argument):
@apiv3_packages_ns.route("/")
class GetPackage(Resource):
- parser = get_package_parser()
-
- @apiv3_packages_ns.expect(parser)
+ @apiv3_packages_ns.expect(package_get_input_model)
@apiv3_packages_ns.marshal_with(package_model)
def get(self):
"""
@@ -171,11 +168,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 +190,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..390b0483d 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
@@ -4,12 +4,12 @@
import flask
from flask_restx import Namespace, Resource
-from coprs.views.misc import api_login_required
+from coprs.views.misc import api_login_required, request_multiple_args
from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper
-from coprs.views.apiv3_ns.schema import (
+from coprs.views.apiv3_ns.schema.schemas import (
project_chroot_model,
project_chroot_build_config_model,
- project_chroot_parser,
+ project_chroot_get_input_model,
)
from coprs.logic.complex_logic import ComplexLogic, BuildConfigLogic
from coprs.exceptions import ObjectNotFound, InvalidForm
@@ -75,35 +75,37 @@ def rename_fields(input_dict):
@apiv3_project_chroots_ns.route("/")
class ProjectChroot(Resource):
- parser = project_chroot_parser()
-
- @apiv3_project_chroots_ns.expect(parser)
+ @apiv3_project_chroots_ns.expect(project_chroot_get_input_model)
@apiv3_project_chroots_ns.marshal_with(project_chroot_model)
def get(self):
"""
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)
+ # pylint: disable-next=unbalanced-tuple-unpacking
+ ownername, projectname, chrootname = request_multiple_args(
+ "ownername", "projectname", "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)
+ @apiv3_project_chroots_ns.expect(project_chroot_get_input_model)
@apiv3_project_chroots_ns.marshal_with(project_chroot_build_config_model)
def get(self):
"""
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)
+ # pylint: disable-next=unbalanced-tuple-unpacking
+ ownername, projectname, chrootname = request_multiple_args(
+ "ownername", "projectname", "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..7c9a7f6ca 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,51 @@
+# 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, request_multiple_args
+from coprs.views.apiv3_ns import rename_fields_helper, api
+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,
+ project_get_input_model,
+ pagination_project_model,
+)
+from coprs.views.apiv3_ns.schema.docs import fullname_docs, ownername_docs, 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 +72,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 +115,384 @@ 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):
+ @apiv3_projects_ns.expect(project_get_input_model)
+ @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):
+ """
+ Get a project
+ Get details for a single Copr project according to ownername and projectname.
+ """
+ # pylint: disable-next=unbalanced-tuple-unpacking
+ ownername, projectname = request_multiple_args("ownername", "projectname")
+ copr = get_copr(ownername, projectname)
+ return to_dict(copr)
+
+
+@apiv3_projects_ns.route("/list")
+class ProjectList(Resource):
+ @restx_pagination
+ @apiv3_projects_ns.doc(ownername_docs)
+ @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
+ @apiv3_projects_ns.doc(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(ownername_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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(fullname_docs)
+ @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..09cfe91a1
--- /dev/null
+++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/docs.py
@@ -0,0 +1,32 @@
+"""
+File for documentation for path parameters in our API for Flask-restx
+"""
+
+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": "Search projects according this keyword."}
+
+ownername_docs = _generate_docs({"ownername"})
+
+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..cfc8c640e
--- /dev/null
+++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py
@@ -0,0 +1,415 @@
+"""
+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
+
+from coprs.constants import CommonDescriptions
+
+# TODO: split these fields to some hierarchy e.g. using dataclasses or to some clusters
+
+# If you find that some descriptions/examples can be shared between forms and
+# fields, please specify it in CommonDescriptions
+
+id_field = Integer(
+ description="Numeric ID",
+ example=123,
+)
+
+mock_chroot = String(
+ description=CommonDescriptions.MOCK_CHROOT.description,
+ example=CommonDescriptions.MOCK_CHROOT.default,
+)
+
+ownername = String(
+ description="User name or group name (starts with @)",
+ example="@copr",
+)
+
+full_name = String(
+ description="Full name of the project in format ownername/projectname",
+ example="@copr/pull-requests",
+)
+
+projectname = String(
+ description="Name of the project",
+ example="copr-dev",
+)
+
+project_dirname = String(
+ description="Path to directory in project separated by colon",
+ example="copr-dev:pr:123",
+)
+
+packagename = String(
+ description="Name of the package in project",
+ example="copr-cli",
+)
+
+package_name = packagename
+
+comps_name = String(
+ description="Name of the comps.xml file",
+)
+
+additional_repos = List(
+ String,
+ description=CommonDescriptions.ADDITIONAL_REPOS.description,
+)
+
+additional_packages = List(
+ String,
+ description=CommonDescriptions.ADDITIONAL_PACKAGES.description,
+)
+
+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=CommonDescriptions.ENABLE_NET.description,
+)
+
+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=CommonDescriptions.PYPI_PACKAGE_NAME.description,
+ example="copr",
+)
+
+pypi_package_version = String(
+ description=CommonDescriptions.PYPI_PACKAGE_VERSION.description,
+ example="1.128pre",
+)
+
+spec_generator = String(
+ description=CommonDescriptions.SPEC_GENERATOR.description,
+ 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=CommonDescriptions.AUTO_REBUILD.description,
+)
+
+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"],
+)
+
+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()
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..80596bd33
--- /dev/null
+++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py
@@ -0,0 +1,420 @@
+# pylint: disable=missing-class-docstring, too-many-instance-attributes
+# pylint: disable=unused-private-member
+
+"""
+File for schemas, models and data validation for our API
+"""
+
+
+# dataclasses are written that way we can easily switch to marshmallow/pydantic
+# as flask-restx docs suggests if needed
+
+
+# TODO: in case we will use marshmallow/pydantic, we should share these schemas
+# somewhere - CLI, Frontend and backend shares these data with each other
+
+
+from dataclasses import dataclass, fields, asdict, MISSING
+from functools import cached_property, 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
+
+
+@dataclass
+class Schema:
+ @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] = 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:
+ 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 in d:
+ if key.startswith("_"):
+ 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()
+ kls = cls(**schema_dict)
+ setattr(
+ kls,
+ "__schema_dict",
+ cls._convert_schema_class_dict_to_schema(schema_dict),
+ )
+ return kls
+
+ @cached_property
+ def schema(self):
+ """
+ Get schema dictionary with properly named key values.
+ """
+ schema_dict = getattr(self, "__schema_dict", None)
+ if schema_dict is None:
+ schema_dict = self._convert_schema_class_dict_to_schema(
+ asdict(self)
+ )
+
+ return schema_dict
+
+ def model(self):
+ """
+ Get Flask-restx model for the schema class.
+ """
+ return api.model(self.__class__.__name__, self.schema)
+
+
+class InputSchema(Schema):
+ @property
+ def required_attrs(self) -> list:
+ """
+ Specify required attributes in model in these methods if needed.
+ """
+ return []
+
+ def input_model(self):
+ """
+ Returns an input model (input to @ns.expect()) with properly set required
+ parameters.
+ """
+ change_this_args_as_required = self.required_attrs
+ if getattr(self, "__all_required", False):
+ change_this_args_as_required = fields(self)
+
+ for field in change_this_args_as_required:
+ if "__all_required" == field:
+ continue
+
+ field.required = True
+
+ return api.model(self.__class__.__name__, self.schema)
+
+
+@dataclass
+class PaginationMeta(Schema):
+ limit: Integer
+ offset: Integer
+ order: String
+ order_type: String
+
+
+_pagination_meta_model = PaginationMeta.get_cls().model()
+
+
+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):
+ 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(InputSchema):
+ 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(InputSchema):
+ 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 ProjectGet(InputSchema):
+ ownername: String
+ projectname: String
+
+ __all_required: bool = True
+
+
+# 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_get_input_model = PackageGet.get_cls().input_model()
+package_add_input_model = PackageAdd.get_cls().input_model()
+package_edit_input_model = package_add_input_model
+
+project_chroot_get_input_model = ProjectChrootGet.get_cls().input_model()
+
+project_get_input_model = ProjectGet.get_cls().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()
diff --git a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_builds.py b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_builds.py
index 51eb97965..18f42f2c2 100644
--- a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_builds.py
+++ b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_builds.py
@@ -9,6 +9,7 @@
from coprs import helpers
from coprs import models
+from coprs.constants import CommonDescriptions
from coprs.logic import builds_logic
from coprs.logic.builds_logic import BuildsLogic
from coprs.logic.complex_logic import ComplexLogic
@@ -271,7 +272,8 @@ def render_add_build_pypi(copr, form, view, package=None):
if not form:
form = forms.BuildFormPyPIFactory(copr.active_chroots)()
return flask.render_template("coprs/detail/add_build/pypi.html",
- copr=copr, form=form, view=view, package=package)
+ copr=copr, form=form, view=view, package=package,
+ common_descriptions=CommonDescriptions)
@coprs_ns.route("///new_build_pypi/", methods=["POST"])
diff --git a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_packages.py b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_packages.py
index ac6089247..09ece52b3 100644
--- a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_packages.py
+++ b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_packages.py
@@ -6,6 +6,7 @@
from coprs import db
from coprs import forms
from coprs import helpers
+from coprs.constants import CommonDescriptions
from coprs.views.coprs_ns import coprs_ns
from coprs.views.coprs_ns.coprs_builds import (
render_add_build_scm,
@@ -185,7 +186,8 @@ def copr_add_package(copr, source_type_text="scm", **kwargs):
form_scm=form["scm"], form_pypi=form["pypi"],
form_rubygems=form["rubygems"],
form_distgit=form['distgit'],
- form_custom=form['custom'])
+ form_custom=form['custom'],
+ common_descriptions=CommonDescriptions)
@coprs_ns.route("///package/new/", methods=["POST"])
@@ -234,7 +236,8 @@ def copr_edit_package(copr, package_name, source_type_text=None, **kwargs):
form_scm=form["scm"], form_pypi=form["pypi"],
form_rubygems=form["rubygems"],
form_distgit=form["distgit"],
- form_custom=form['custom'])
+ form_custom=form['custom'],
+ common_descriptions=CommonDescriptions)
@coprs_ns.route("///package//edit/", methods=["POST"])
diff --git a/frontend/coprs_frontend/coprs/views/misc.py b/frontend/coprs_frontend/coprs/views/misc.py
index 8f6db88f7..8345fce92 100644
--- a/frontend/coprs_frontend/coprs/views/misc.py
+++ b/frontend/coprs_frontend/coprs/views/misc.py
@@ -2,9 +2,11 @@
import datetime
import functools
from functools import wraps
+from http import HTTPStatus
+
import flask
-from flask import send_file
+from flask import send_file, jsonify
from copr_common.enums import RoleEnum
from coprs import app
@@ -14,10 +16,11 @@
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
+from coprs.helpers import multiple_get
from coprs import oidc
@app.before_request
@@ -156,41 +159,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 +319,79 @@ def wrapper(*args, **kwargs):
raise ObjectNotFound("Invalid pagination format") from err
return f(*args, page=page, **kwargs)
return wrapper
+
+
+# Flask-restx specific decorator - 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):
+ """
+
+ Args:
+ endpoint_method:
+
+ Returns:
+
+ """
+ @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
+
+
+def make_response(content, status=HTTPStatus.OK):
+ """
+ Make Flask response with specified status
+ """
+ response = jsonify(content)
+ response.status_code = status.value
+ return response
+
+
+def payload_multiple_get(payload: dict, *parameters) -> list:
+ """
+ Get multiple values from dictionary.
+
+ Args:
+ payload: Any dictionary obtain from API request
+ *parameters: list of parameters to obtain values from request
+ Returns:
+ *parameters values in the same order as they were given.
+ """
+ try:
+ return multiple_get(payload, parameters)
+ except KeyError as exc:
+ raise BadRequest(str(exc)) from exc
+
+
+def request_multiple_args(*query_params) -> list:
+ """
+ Get multiple values from query parameters.
+
+ Args:
+ missing query parameters
+ *query_params: list of args to obtain values from flask.request.args
+ Returns:
+ *args values in the same order as they were given.
+ """
+ result = []
+ flask_args = flask.request.args
+ empty = "__empty_content"
+ for arg in query_params:
+ flask_arg = flask_args.get(arg, empty)
+ if flask_arg == empty:
+ raise BadRequest(f"Missing arg: {arg}")
+
+ result.append(flask_arg)
+
+ return result