From f35ec4ae2b8791ef41aefc378798fa1f97a00599 Mon Sep 17 00:00:00 2001 From: sundaram123krishnan Date: Wed, 26 Aug 2026 22:07:17 +0530 Subject: [PATCH] frontend, cli, python: add project tags Fixes: #1919 --- cli/copr_cli/main.py | 7 ++ cli/man/copr-cli.1.asciidoc | 10 ++ cli/tests/test_cli.py | 2 + frontend/copr-frontend.spec | 2 + .../versions/fe7f7d55dde3_add_project_tags.py | 49 +++++++++ frontend/coprs_frontend/coprs/forms.py | 102 +++++++++++++++++- .../coprs_frontend/coprs/logic/coprs_logic.py | 99 ++++++++++++++++- frontend/coprs_frontend/coprs/models.py | 39 +++++++ .../coprs/static/css/custom-styles.css | 4 + .../coprs/templates/_helpers.html | 9 ++ .../coprs/templates/admin/layout.html | 1 + .../coprs/templates/admin/tags.html | 61 +++++++++++ .../coprs/templates/coprs/_coprs_forms.html | 20 ++++ .../templates/coprs/detail/overview.html | 16 +++ .../coprs/templates/coprs/show/by_tag.html | 16 +++ .../coprs/views/admin_ns/admin_general.py | 70 ++++++++++++ .../coprs/views/apiv3_ns/__init__.py | 5 +- .../coprs/views/apiv3_ns/apiv3_projects.py | 12 ++- .../coprs/views/apiv3_ns/schema/schemas.py | 5 + .../coprs/views/coprs_ns/coprs_general.py | 35 +++++- .../tests/test_apiv3/test_projects.py | 49 ++++++++- frontend/coprs_frontend/tests/test_forms.py | 74 +++++++++++++ .../tests/test_logic/test_coprs_logic.py | 99 ++++++++++++++++- .../test_admin/test_admin_general.py | 60 +++++++++++ .../test_coprs_ns/test_coprs_general.py | 22 +++- frontend/requirements.txt | 1 + python/copr/v3/proxies/project.py | 8 +- 27 files changed, 860 insertions(+), 17 deletions(-) create mode 100644 frontend/coprs_frontend/alembic/versions/fe7f7d55dde3_add_project_tags.py create mode 100644 frontend/coprs_frontend/coprs/templates/admin/tags.html create mode 100644 frontend/coprs_frontend/coprs/templates/coprs/show/by_tag.html diff --git a/cli/copr_cli/main.py b/cli/copr_cli/main.py index 9347dfaad..42fa23faa 100644 --- a/cli/copr_cli/main.py +++ b/cli/copr_cli/main.py @@ -712,6 +712,7 @@ def action_create(self, args): packit_forge_projects_allowed=args.packit_forge_projects_allowed, repo_priority=args.repo_priority, storage=args.storage, + tags=args.tags, ) owner_part = username.replace('@', "g/") @@ -746,6 +747,7 @@ def action_modify_project(self, args): runtime_dependencies=args.runtime_dependencies, packit_forge_projects_allowed=args.packit_forge_projects_allowed, repo_priority=args.repo_priority, + tags=args.tags, ) @requires_api_auth @@ -1306,6 +1308,11 @@ def create_and_modify_common_opts(parser): "all repositories in the theproject organization). " "Can be specified multiple times." )) + parser.add_argument( + "--tags", dest="tags", metavar="TAG", action="append", help=( + "Tag to attach to this project, e.g. cli. " + "Can be specified multiple times." + )) def setup_parser(): diff --git a/cli/man/copr-cli.1.asciidoc b/cli/man/copr-cli.1.asciidoc index 41566c6a9..d5809e490 100644 --- a/cli/man/copr-cli.1.asciidoc +++ b/cli/man/copr-cli.1.asciidoc @@ -116,6 +116,7 @@ usage: copr-cli create [-h] --chroot CHROOTS [--repo REPOS] [--persistent] [--auto-prune {on,off}] [--isolation {default, nspawn, simple}] + [--tags TAG] name --chroot:: @@ -124,6 +125,9 @@ Chroot to use for this project. Can be specified multiple times, but at least on --repo:: Repository to add to this project. Can be specified multiple times. +--tags:: +Tag to attach to this project, e.g. cli. Can be specified multiple times. + --initial-pkgs:: List of packages to build in this new project. Can be specified multiple times. @@ -166,6 +170,7 @@ usage: copr-cli modify [-h] [--repo REPOS] [--unlisted-on-hp {on,off}] [--auto-prune {on,off}] [--isolation {default, nspawn, simple}] + [--tags TAG] name Alters only specified project property. @@ -179,6 +184,11 @@ When this option is not used, chroots in the project remain unchanged. Once you specify a chroot, it is going to be enabled in the project, but current chroots will not be preserved if they are not specified. +--tags:: +Tag to attach to this project, e.g. cli. Can be specified multiple times. +When this option is not used, tags on the project remain unchanged; once +specified, it replaces the full set of tags on the project. + --description:: Description of the project. diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 09bf98c71..3af01fa8c 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -576,6 +576,7 @@ def test_create_project(config_from_file, project_proxy_add, capsys): "packit_forge_projects_allowed": None, "repo_priority": None, "storage": None, + "tags": None, } assert stdout == "New project was successfully created: http://copr/coprs/jdoe/foo/\n" @@ -674,6 +675,7 @@ def test_create_multilib_project(config_from_file, project_proxy_add, capsys): "packit_forge_projects_allowed": None, "repo_priority": None, "storage": None, + "tags": None, } assert stdout == "New project was successfully created: http://copr/coprs/jdoe/foo/\n" diff --git a/frontend/copr-frontend.spec b/frontend/copr-frontend.spec index 708de1315..5790699a8 100644 --- a/frontend/copr-frontend.spec +++ b/frontend/copr-frontend.spec @@ -80,6 +80,7 @@ BuildRequires: python3dist(beautifulsoup4) BuildRequires: python3dist(copr-common) >= %copr_common_version BuildRequires: python3dist(email-validator) BuildRequires: python3dist(python-dateutil) +BuildRequires: python3dist(python-slugify) BuildRequires: python3dist(decorator) BuildRequires: python3dist(flask) BuildRequires: python3dist(templated-dictionary) @@ -137,6 +138,7 @@ Requires: python3dist(alembic) Requires: python3dist(blinker) Requires: python3dist(copr-common) >= %copr_common_version Requires: python3dist(python-dateutil) +Requires: python3dist(python-slugify) Requires: python3dist(email-validator) Requires: python3dist(flask) Requires: python3dist(flask-caching) diff --git a/frontend/coprs_frontend/alembic/versions/fe7f7d55dde3_add_project_tags.py b/frontend/coprs_frontend/alembic/versions/fe7f7d55dde3_add_project_tags.py new file mode 100644 index 000000000..347500e4c --- /dev/null +++ b/frontend/coprs_frontend/alembic/versions/fe7f7d55dde3_add_project_tags.py @@ -0,0 +1,49 @@ +""" +add project tags + +Revision ID: fe7f7d55dde3 +Create Date: 2026-08-23 12:23:00.181615 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'fe7f7d55dde3' +down_revision = 'e31b4af2468c' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'project_tag', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('is_default', sa.Boolean(), server_default='0', nullable=False), + sa.Column('created_on', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + op.create_index(op.f('ix_project_tag_name'), 'project_tag', ['name'], unique=True) + + op.create_table( + 'copr_project_tag', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('copr_id', sa.Integer(), nullable=False), + sa.Column('project_tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['copr_id'], ['copr.id'], ), + sa.ForeignKeyConstraint(['project_tag_id'], ['project_tag.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('copr_id', 'project_tag_id', + name='copr_project_tag_copr_id_project_tag_id_uniq'), + ) + op.create_index(op.f('ix_copr_project_tag_copr_id'), 'copr_project_tag', ['copr_id'], unique=False) + op.create_index(op.f('ix_copr_project_tag_project_tag_id'), 'copr_project_tag', ['project_tag_id'], unique=False) + + +def downgrade(): + op.drop_table('copr_project_tag') + op.drop_table('project_tag') diff --git a/frontend/coprs_frontend/coprs/forms.py b/frontend/coprs_frontend/coprs/forms.py index df7fc26f1..cffb17f4a 100644 --- a/frontend/coprs_frontend/coprs/forms.py +++ b/frontend/coprs_frontend/coprs/forms.py @@ -7,6 +7,7 @@ import flask import wtforms +from slugify import slugify from flask_wtf.file import FileRequired, FileField, MultipleFileField @@ -20,7 +21,8 @@ from coprs import exceptions from coprs import helpers from coprs import models -from coprs.logic.coprs_logic import CoprsLogic, MockChrootsLogic + +from coprs.logic.coprs_logic import CoprsLogic, MockChrootsLogic, ProjectTagsLogic from coprs.logic.users_logic import UsersLogic from coprs.logic.dist_git_logic import DistGitLogic from coprs.logic.complex_logic import ComplexLogic @@ -213,6 +215,89 @@ def __init__(self, label="", validators=None, copr=None, **kwargs): self.default = [ch for ch in active_names if ch in copr_chroot_names] +class AdditionalProjectTagsField(wtforms.StringField): + """ + Input fields for project tags + """ + def __init__(self, label="", validators=None, copr=None, **kwargs): + """ + Pre-fill the field with `copr`'s existing non-default tag names, if given. + """ + super().__init__(label, validators, **kwargs) + self.label = label or "Additional tags" + self.data = "" + if copr: + custom_names = [t.name for t in copr.project_tags if not t.is_default] + self.default = ", ".join(custom_names) + + def process_formdata(self, valuelist): + """ + Join multiple submitted values into comma separated string + """ + self.data = ", ".join(valuelist) if valuelist else "" + + def _value(self): + """ + Render the field's current data back into its comma-separated string form. + """ + if self.data: + return ", ".join(self.data) + return "" + + +class ProjectTagsFilter: + """ + Turn comma separated tag names into deduplicated, slugified tag names. + """ + @staticmethod + def normalize_tag_name(name): + """ + Remove non-ascii characters and slugify the tag name. + """ + encoded_name = name.encode("ascii", "ignore").decode("ascii") + slugified_name = slugify(encoded_name, max_length=50) + return slugified_name + + def __call__(self, value): + """ + Split a comma-separated string into cleaned, deduplicated tag names. + """ + if not value: + return [] + cleaned = [] + for name in value.split(","): + normalized_name = self.normalize_tag_name(name) + if normalized_name and len(normalized_name) >= 3: + cleaned.append(normalized_name) + return list(dict.fromkeys(cleaned)) + + +class DefaultTagsField(MultiCheckboxField): + """ + Checkboxes for default tags + """ + # pylint: disable=too-few-public-methods + def __init__(self, label="", validators=None, copr=None, **kwargs): + """ + Build the default-tag checkbox choices and pre-tick `copr`'s own default tags, if given. + """ + super().__init__(label, validators, **kwargs) + self.label = label or "Default tags" + + default_tag_names = [t.name for t in ProjectTagsLogic.get_default_tags()] + self.choices = [(name, name) for name in default_tag_names] + self.default = self.ticked_default_names(copr) + + @staticmethod + def ticked_default_names(copr): + """ + Display the default tags that were already selected. + """ + if not copr: + return [] + return [t.name for t in copr.project_tags if t.is_default] + + class UrlListValidator(object): def __init__(self, message=None): @@ -589,6 +674,13 @@ class CoprForm(BaseForm): chroots = ChrootsField() + default_tags = DefaultTagsField() + + tags = AdditionalProjectTagsField( + "Additional tags", + filters=[ProjectTagsFilter()], + ) + description = wtforms.TextAreaField("Description") instructions = wtforms.TextAreaField("Instructions") @@ -806,6 +898,14 @@ class F(CoprForm): # a list of default chroots based on `copr` chroots = ChrootsField(copr=copr) + # We are redefining the original `CoprForm` field because we need to + # pre-tick the default tags already set on `copr` + default_tags = DefaultTagsField(copr=copr) + + # We are redefining the original `CoprForm` field because we need to + # pre-fill the project's existing non-default tags + tags = AdditionalProjectTagsField(copr=copr, filters=[ProjectTagsFilter()]) + @property def selected_chroots(self): return self.chroots.data diff --git a/frontend/coprs_frontend/coprs/logic/coprs_logic.py b/frontend/coprs_frontend/coprs/logic/coprs_logic.py index d678ee529..40fdb03ad 100644 --- a/frontend/coprs_frontend/coprs/logic/coprs_logic.py +++ b/frontend/coprs_frontend/coprs/logic/coprs_logic.py @@ -14,7 +14,7 @@ from sqlalchemy import func from sqlalchemy.event import listens_for from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.attributes import NEVER_SET, NO_VALUE from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.attributes import get_history @@ -53,6 +53,8 @@ def get_all(cls): query = (db.session.query(models.Copr) .join(models.Copr.user) .options(db.contains_eager(models.Copr.user)) + .options(selectinload(models.Copr.copr_project_tags) + .joinedload(models.CoprProjectTag.project_tag)) .filter(models.Copr.deleted == False)) return query @@ -133,6 +135,8 @@ def get_multiple(cls, include_deleted=False, include_unlisted_on_hp=True): .join(models.Copr.user) .outerjoin(models.Group) .options(db.contains_eager(models.Copr.user)) + .options(selectinload(models.Copr.copr_project_tags) + .joinedload(models.CoprProjectTag.project_tag)) ) if not include_deleted: @@ -259,7 +263,9 @@ def get_multiple_fulltext(cls, fulltext=None, projectname=None, fulltext = None query = (models.Copr.query.order_by(desc(models.Copr.created_on)) - .filter(models.Copr.deleted == False)) + .filter(models.Copr.deleted == False) + .options(selectinload(models.Copr.copr_project_tags) + .joinedload(models.CoprProjectTag.project_tag))) if projectname: value = "%{}%".format(projectname) @@ -286,6 +292,18 @@ def get_multiple_fulltext(cls, fulltext=None, projectname=None, return query + @classmethod + def get_copr_projects_by_tag_name(cls, tag_name): + """ + Get all coprs that have the given project tag + """ + query = cls.get_multiple() + return ( + query.join(models.Copr.copr_project_tags) + .join(models.CoprProjectTag.project_tag) + .filter(models.ProjectTag.name == tag_name) + ) + @classmethod def add(cls, user, name, selected_chroots, repos=None, description=None, instructions=None, check_for_duplicates=False, group=None, persistent=False, @@ -498,6 +516,83 @@ def raise_if_packit_forge_project_cant_build_in_copr(cls, copr, packit_forge_pro ) +class ProjectTagsLogic: + """ + Logic for project tags + """ + @classmethod + def get_tag(cls, name): + """ + Get a project tag by its name + """ + return models.ProjectTag.query.filter(models.ProjectTag.name == name).first() + + @classmethod + def new_tag(cls, name, user=None): + """ + Create new project tag + """ + tag = models.ProjectTag(name=name, created_on=int(time.time()), user=user) + db.session.add(tag) + return tag + + @classmethod + def create_tag(cls, name, user=None): + """ + Get or create a project tag + """ + tag = cls.get_tag(name) + if tag: + return tag + return cls.new_tag(name, user=user) + + @classmethod + def get_tags_by_names(cls, names): + """ + Get existing project tags matching names + """ + return {t.name: t for t in + models.ProjectTag.query.filter(models.ProjectTag.name.in_(names))} + + @classmethod + def get_default_tags(cls): + """ + Get only the default (admin-curated) project tags + """ + return (models.ProjectTag.query + .filter_by(is_default=True) + .options(joinedload(models.ProjectTag.user)) + .order_by(models.ProjectTag.name)) + + @classmethod + def set_copr_tags(cls, copr, tag_names, user=None): + """ + Set the tags for a copr project + """ + names = list(dict.fromkeys(tag_names)) + if len(names) > 10: + raise exceptions.BadRequest("A project can have at most 10 tags.") + + existing = cls.get_tags_by_names(names) + new_tags = [existing.get(name) or cls.new_tag(name, user=user) for name in names] + + current_tags = list(copr.project_tags) + for tag in current_tags: + if tag not in new_tags: + copr.project_tags.remove(tag) + for tag in new_tags: + if tag not in current_tags: + copr.project_tags.append(tag) + + @classmethod + def delete_tag(cls, tag): + """ + Delete a project tag + """ + models.CoprProjectTag.query.filter_by(project_tag_id=tag.id).delete() + db.session.delete(tag) + + class CoprPermissionsLogic(object): @classmethod def get(cls, copr, searched_user): diff --git a/frontend/coprs_frontend/coprs/models.py b/frontend/coprs_frontend/coprs/models.py index 4317ac65c..c4b8c3ef1 100644 --- a/frontend/coprs_frontend/coprs/models.py +++ b/frontend/coprs_frontend/coprs/models.py @@ -308,6 +308,42 @@ class CoprScore(db.Model, helpers.Serializer): name="copr_score_copr_id_user_id_uniq"), ) + +class ProjectTag(db.Model, helpers.Serializer): + """ + Project tags attached to copr projects. + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False, unique=True, index=True) + # default tags that would appear when creating a copr project + # configured by the admin + is_default = db.Column(db.Boolean, default=False, nullable=False, + server_default="0") + created_on = db.Column(db.Integer) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + user = db.relationship("User") + + +class CoprProjectTag(db.Model, helpers.Serializer): + """ + Many to many relation between copr and project_tag. + """ + id = db.Column(db.Integer, primary_key=True) + copr_id = db.Column(db.Integer, db.ForeignKey("copr.id"), nullable=False, index=True) + project_tag_id = db.Column(db.Integer, db.ForeignKey("project_tag.id"), nullable=False, index=True) + + project_tag = db.relationship("ProjectTag") + copr = db.relationship("Copr", + backref=db.backref( + "copr_project_tags", + cascade="all,delete,delete-orphan")) + + __table_args__ = ( + db.UniqueConstraint("copr_id", "project_tag_id", + name="copr_project_tag_copr_id_project_tag_id_uniq"), + ) + + _group_unique_where = text("deleted is not true and group_id is not null") _user_unique_where = text("deleted is not true and group_id is null") @@ -453,6 +489,9 @@ class Copr(db.Model, helpers.Serializer, CoprSearchRelatedData): # relations user = db.relationship("User", backref=db.backref("coprs")) group = db.relationship("Group", backref=db.backref("groups")) + project_tags = association_proxy( + "copr_project_tags", "project_tag", + creator=lambda project_tag: CoprProjectTag(project_tag=project_tag)) mock_chroots = association_proxy("copr_chroots", "mock_chroot") forked_from = db.relationship("Copr", remote_side=_CoprPublic.id, diff --git a/frontend/coprs_frontend/coprs/static/css/custom-styles.css b/frontend/coprs_frontend/coprs/static/css/custom-styles.css index 045980b48..c17ab898f 100644 --- a/frontend/coprs_frontend/coprs/static/css/custom-styles.css +++ b/frontend/coprs_frontend/coprs/static/css/custom-styles.css @@ -62,3 +62,7 @@ input.input-field-12em { margin-top: 10px; margin-right: 20px; } + +.tag-btn { + margin: 0 4px 4px 0; +} diff --git a/frontend/coprs_frontend/coprs/templates/_helpers.html b/frontend/coprs_frontend/coprs/templates/_helpers.html index f2585c11a..c1a194a7a 100644 --- a/frontend/coprs_frontend/coprs/templates/_helpers.html +++ b/frontend/coprs_frontend/coprs/templates/_helpers.html @@ -844,6 +844,15 @@

{% endfor %} + {% if copr.project_tags %} +
  • + Tags: + + {% for tag in copr.project_tags[:6] %}{{ tag.name }}{% if not loop.last %}, {% endif %}{% endfor %} + {% if copr.project_tags|length > 6 %}, +{{ copr.project_tags|length - 6 }} more{% endif %} + +
  • + {% endif %} diff --git a/frontend/coprs_frontend/coprs/templates/admin/layout.html b/frontend/coprs_frontend/coprs/templates/admin/layout.html index 84e829086..818c2d2e2 100644 --- a/frontend/coprs_frontend/coprs/templates/admin/layout.html +++ b/frontend/coprs_frontend/coprs/templates/admin/layout.html @@ -29,6 +29,7 @@

    Copr Administration

    diff --git a/frontend/coprs_frontend/coprs/templates/admin/tags.html b/frontend/coprs_frontend/coprs/templates/admin/tags.html new file mode 100644 index 000000000..802deb35a --- /dev/null +++ b/frontend/coprs_frontend/coprs/templates/admin/tags.html @@ -0,0 +1,61 @@ +{% extends "admin/layout.html" %} + +{% set selected_tab = "tags" %} + +{% block admin_body %} +

    Tags

    + +
    + + + Creates the tag (if it doesn't already exist) and marks it as default immediately. Comma-separated for multiple. +
    + + + + + + + + + + + + {% for tag in tags %} + + + + + + + {% else %} + + + + {% endfor %} + +
    NameCreated byCreated on
    +
    + + +
    +
    + {% if tag.user %} + {{ tag.user.name }} + {% else %} + system + {% endif %} + + {% if tag.created_on %} + {{ tag.created_on | time_ago() }} ago + {% else %} + unknown + {% endif %} + +
    + +
    +
    No tags have been created yet.
    + +{% endblock %} diff --git a/frontend/coprs_frontend/coprs/templates/coprs/_coprs_forms.html b/frontend/coprs_frontend/coprs/templates/coprs/_coprs_forms.html index d94a18544..077806dd6 100644 --- a/frontend/coprs_frontend/coprs/templates/coprs/_coprs_forms.html +++ b/frontend/coprs_frontend/coprs/templates/coprs/_coprs_forms.html @@ -42,6 +42,26 @@

    {{ counter('instructions') }}. Project information

    {{ render_field(form.description, rows=5, cols=50, placeholder='Optional - describe your project briefly.', info='You can use markdown syntax, inline HTML is forbidden.') }} + {% if form.default_tags.choices %} +
    + +
    + {% for tag in form.default_tags %} +
    + +
    + {% endfor %} +
    +
    + {% endif %} + + {{ render_field(form.tags, placeholder='Optional - comma separated tags, e.g. cli, devtools, monitoring', info='Add any tag not in the default list above; new tags are created automatically.') }} + {{ render_field(form.instructions, rows=5, cols=50, placeholder='Optional - describe how your project can be installed. Where users can report bugs and issues. Link to wiki is good as well.', info='You can use markdown syntax, inline HTML is forbidden.') }} {{ render_field(form.homepage, label='Homepage', placeholder='Optional - project homepage') }} diff --git a/frontend/coprs_frontend/coprs/templates/coprs/detail/overview.html b/frontend/coprs_frontend/coprs/templates/coprs/detail/overview.html index ba08f5e16..a9b139b27 100644 --- a/frontend/coprs_frontend/coprs/templates/coprs/detail/overview.html +++ b/frontend/coprs_frontend/coprs/templates/coprs/detail/overview.html @@ -159,6 +159,22 @@

    Modified {{ name }}:

    {% endif %} + + {% if copr.project_tags %} +
    +
    +

    Tags

    +
    +
    + {% for tag in copr.project_tags %} + + {{ tag.name }} + + {% endfor %} +
    +
    + {% endif %}

    Last Build

    diff --git a/frontend/coprs_frontend/coprs/templates/coprs/show/by_tag.html b/frontend/coprs_frontend/coprs/templates/coprs/show/by_tag.html new file mode 100644 index 000000000..9afb10d59 --- /dev/null +++ b/frontend/coprs_frontend/coprs/templates/coprs/show/by_tag.html @@ -0,0 +1,16 @@ +{% extends "coprs/show.html" %} +{% block title %}Projects tagged '{{ tag_name }}'{% endblock %} +{% block header %}Projects tagged '{{ tag_name }}'{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block show_top %} +

    Projects tagged {{ tag_name }}

    +{% endblock %} diff --git a/frontend/coprs_frontend/coprs/views/admin_ns/admin_general.py b/frontend/coprs_frontend/coprs/views/admin_ns/admin_general.py index 044f6bbeb..e8e7b1d9c 100644 --- a/frontend/coprs_frontend/coprs/views/admin_ns/admin_general.py +++ b/frontend/coprs_frontend/coprs/views/admin_ns/admin_general.py @@ -2,6 +2,7 @@ import re import flask +from sqlalchemy.exc import IntegrityError from copr_common.enums import RoleEnum from coprs import db @@ -9,6 +10,7 @@ from coprs import forms from coprs.logic import coprs_logic +from coprs.logic.coprs_logic import ProjectTagsLogic from coprs.views.admin_ns import admin_ns from coprs.views.misc import login_required @@ -51,6 +53,74 @@ def legal_flag_resolve(flag_id): return flask.redirect(flask.url_for("admin_ns.legal_flag")) +@admin_ns.route("/tags/") +@login_required(role=RoleEnum("admin")) +def tags(): + """ + List the default (admin-curated) project tags for management. + """ + return flask.render_template("admin/tags.html", + tags=ProjectTagsLogic.get_default_tags().all()) + + +@admin_ns.route("/tags/create/", methods=["POST"]) +@login_required(role=RoleEnum("admin")) +def tag_create(): + """ + Create (or reuse) one or more tags by name and mark them as default. + """ + names = forms.ProjectTagsFilter()(flask.request.form.get("name", "")) + if not names: + flask.flash("Please provide at least one valid tag name.", "error") + else: + for name in names: + tag = ProjectTagsLogic.create_tag(name, user=flask.g.user) + tag.is_default = True + db.session.commit() + flask.flash("Created default tag(s): {0}".format(", ".join(names))) + return flask.redirect(flask.url_for("admin_ns.tags")) + + +@admin_ns.route("/tags//rename/", methods=["POST"]) +@login_required(role=RoleEnum("admin")) +def tag_rename(tag_id): + """ + Rename an existing tag + """ + tag = models.ProjectTag.query.get_or_404(tag_id) + new_name = forms.ProjectTagsFilter.normalize_tag_name( + flask.request.form.get("name", "")) + + if not new_name or len(new_name) < 3: + flask.flash("Please provide a valid tag name (at least 3 characters).", "error") + return flask.redirect(flask.url_for("admin_ns.tags")) + + old_name = tag.name + tag.name = new_name + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + flask.flash(f"A tag named '{new_name}' already exists.", "error") + else: + flask.flash(f"Renamed tag '{old_name}' to '{new_name}'.") + return flask.redirect(flask.url_for("admin_ns.tags")) + + +@admin_ns.route("/tags//delete/", methods=["POST"]) +@login_required(role=RoleEnum("admin")) +def tag_delete(tag_id): + """ + Delete a tag, detaching it from every project that had it. + """ + tag = models.ProjectTag.query.get_or_404(tag_id) + name = tag.name + ProjectTagsLogic.delete_tag(tag) + db.session.commit() + flask.flash(f"Deleted tag '{name}' and detached it from all projects.") + return flask.redirect(flask.url_for("admin_ns.tags")) + + @admin_ns.route("/playground/", methods=["POST", "GET"]) @login_required(role=RoleEnum("admin")) def playground(): diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py index 0391c1238..13cb548f6 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/__init__.py @@ -246,7 +246,10 @@ def set_defaults(formdata, form_class): continue if field.name in formdata.keys(): continue - formdata[field.name] = field.default + if isinstance(field.default, list): + formdata.setlist(field.name, field.default) + else: + formdata[field.name] = field.default def streamed_json_array_response(array_or_generator, message, field="data"): 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 0862dfe79..7b76ae140 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_projects.py @@ -33,7 +33,7 @@ ) 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.coprs_logic import CoprsLogic, CoprChrootsLogic, MockChrootsLogic, ProjectTagsLogic from coprs.logic.complex_logic import ComplexLogic from coprs.logic.users_logic import UsersLogic from coprs.exceptions import ( @@ -82,6 +82,7 @@ def to_dict(copr): # but we have this inconsistency between name - projectname "projectname": copr.name, "storage": StorageEnum(copr.storage), + "tags": [t.name for t in copr.project_tags], } @@ -202,7 +203,7 @@ def post(self, ownername, exist_ok=False): """ exist_ok = flask.request.args.get("exist_ok") == "True" user, group = owner2tuple(ownername) - data = rename_fields(get_form_compatible_data(preserve=["chroots"])) + data = rename_fields(get_form_compatible_data(preserve=["chroots", "tags"])) form_class = forms.CoprFormFactory.create_form_cls(user=user, group=group, exist_ok=exist_ok) set_defaults(data, form_class) @@ -267,6 +268,7 @@ def _form_field_repos(form_field): repo_priority=form.repo_priority.data, storage=form.storage.data, ) + ProjectTagsLogic.set_copr_tags(copr, form.tags.data, user=user) db.session.commit() except IntegrityError as ierr: app.logger.debug("Racy attempt to create %s/%s", ownername, projectname) @@ -294,7 +296,7 @@ class ProjectEdit(Resource): @staticmethod def _common(ownername, projectname): copr = get_copr(ownername, projectname) - data = rename_fields(get_form_compatible_data(preserve=["chroots"])) + data = rename_fields(get_form_compatible_data(preserve=["chroots", "tags"])) form = forms.CoprForm(data, meta={"csrf": False}) if not form.validate_on_submit(): @@ -302,7 +304,7 @@ def _common(ownername, projectname): validate_chroots(get_input_dict(), MockChrootsLogic.get_multiple()) for field in form: - if field.data is None or field.name in ["csrf_token", "chroots"]: + if field.data is None or field.name in ["csrf_token", "chroots", "tags", "default_tags"]: continue if field.name not in data.keys(): continue @@ -312,6 +314,8 @@ def _common(ownername, projectname): CoprChrootsLogic.update_from_names(flask.g.user, copr, form.chroots.data) try: + if "tags" in data: + ProjectTagsLogic.set_copr_tags(copr, form.tags.data, user=flask.g.user) CoprsLogic.update(flask.g.user, copr) if copr.group: # load group.id _ = copr.group.id diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py index 39ea438b7..67e16fc60 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py @@ -465,6 +465,11 @@ class _ProjectFields: ), example=["github.com/fedora-copr/copr", "github.com/another/project", "github.com/theproject/*"], ) + tags: List = List( + String, + description="Project tags", + example=["cli", "devtools"], + ) follow_fedora_branching: Boolean = Boolean( description=( "If chroots for the new branch should be auto-enabled and populated from " diff --git a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py index 9144b0158..04cf017cd 100644 --- a/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py +++ b/frontend/coprs_frontend/coprs/views/coprs_ns/coprs_general.py @@ -26,7 +26,7 @@ from coprs import helpers from coprs import models from coprs.exceptions import ObjectNotFound, BadRequest -from coprs.logic.coprs_logic import CoprsLogic, PinnedCoprsLogic, MockChrootsLogic +from coprs.logic.coprs_logic import CoprsLogic, PinnedCoprsLogic, MockChrootsLogic, ProjectTagsLogic from coprs.logic.stat_logic import CounterStatLogic from coprs.logic.webhooks_logic import WebhooksLogic from coprs.mail import send_mail, LegalFlagMessage, PermissionRequestMessage, PermissionChangeMessage @@ -151,6 +151,28 @@ def coprs_by_user(username=None, page=1): graph=data) +@coprs_ns.route("/tags//", defaults={"page": 1}) +@coprs_ns.route("/tags///") +def coprs_by_tag(tag_name, page=1): + """ + List the (paginated) projects tagged with an exact tag name. + """ + query = CoprsLogic.get_copr_projects_by_tag_name(tag_name) + query = CoprsLogic.set_query_order(query, desc=True) + paginator = helpers.Paginator(query, query.count(), page) + coprs = paginator.sliced_query + + data = builds_logic.BuildsLogic.get_small_graph_data('30min') + + return flask.render_template("coprs/show/by_tag.html", + tag_name=tag_name, + coprs=coprs, + pinned=[], + paginator=paginator, + tasks_info=ComplexLogic.get_queue_sizes_cached(), + graph=data) + + @coprs_ns.route("/fulltext/", defaults={"page": 1}) @coprs_ns.route("/fulltext//") def coprs_fulltext_search(page=1): @@ -339,10 +361,14 @@ def copr_new(username=None, group_name=None): repo_priority=form.repo_priority.data ) + ProjectTagsLogic.set_copr_tags( + copr, form.default_tags.data + form.tags.data, user=flask.g.user) + db.session.commit() after_the_project_creation(copr, form) return flask.redirect(url_for_copr_details(copr)) - except (exceptions.DuplicateException, exceptions.NonAdminCannotCreatePersistentProject) as e: + except (exceptions.DuplicateException, exceptions.NonAdminCannotCreatePersistentProject, + exceptions.BadRequest) as e: flask.flash(str(e), "error") return flask.render_template(redirect, form=form, group=group) @@ -621,11 +647,14 @@ def process_copr_update(copr, form): try: coprs_logic.CoprChrootsLogic.update_from_names( flask.g.user, copr, form.selected_chroots) + ProjectTagsLogic.set_copr_tags( + copr, form.default_tags.data + form.tags.data, user=flask.g.user) # form validation checks for duplicates coprs_logic.CoprsLogic.update(flask.g.user, copr) except (exceptions.ActionInProgressException, exceptions.InsufficientRightsException, - exceptions.ConflictingRequest) as e: + exceptions.ConflictingRequest, + exceptions.BadRequest) as e: flask.flash(str(e), "error") db.session.rollback() diff --git a/frontend/coprs_frontend/tests/test_apiv3/test_projects.py b/frontend/coprs_frontend/tests/test_apiv3/test_projects.py index 4d04dfb26..af8683d45 100644 --- a/frontend/coprs_frontend/tests/test_apiv3/test_projects.py +++ b/frontend/coprs_frontend/tests/test_apiv3/test_projects.py @@ -4,6 +4,7 @@ from unittest.mock import patch, MagicMock import pytest +from sqlalchemy import inspect as sa_inspect from coprs.models import User, Copr @@ -75,10 +76,54 @@ def test_isolation_config(self, store, read): self.api3.post(route, data) assert Copr.query.one().isolation == read + @TransactionDecorator("u1") + @pytest.mark.usefixtures("f_users", "f_users_api", "f_mock_chroots", "f_db") + def test_add_project_with_tags(self): + """ + Test that tags are added correctly when adding a project. + """ + route = "/api_3/project/add/{}".format(self.transaction_username) + resp = self.api3.post(route, { + "name": "test-tags", + "chroots": ["fedora-rawhide-i386"], + "tags": ["cli", "monitoring", "devtools"], + }) + assert resp.status_code == 200 + assert sorted(resp.json["tags"]) == ["cli", "devtools", "monitoring"] + copr = Copr.query.filter_by(name="test-tags").one() + assert sorted(t.name for t in copr.project_tags) == \ + ["cli", "devtools", "monitoring"] + + @TransactionDecorator("u1") + @pytest.mark.usefixtures("f_users", "f_users_api", "f_mock_chroots", "f_db") + def test_edit_project_tags_is_partial_update(self): + """ + Editing a project without mentioning tags must leave existing tags untouched. + """ + route = "/api_3/project/add/{}".format(self.transaction_username) + self.api3.post(route, { + "name": "test-tags-edit", + "chroots": ["fedora-rawhide-i386"], + "tags": ["cli"], + }) + + # editing without mentioning tags must leave the existing tag alone + self.api3.modify_project("test-tags-edit", description="new desc") + copr = Copr.query.filter_by(name="test-tags-edit").one() + assert [t.name for t in copr.project_tags] == ["cli"] + + # editing with tags replaces the previous set entirely + self.api3.modify_project("test-tags-edit", tags=["devtools", "review-tool"]) + self.db.session.expire(copr) + assert sorted(t.name for t in copr.project_tags) == \ + ["devtools", "review-tool"] + def _get_copr_id_data(self, copr_id): copr = self.db.session.get(self.models.Copr, copr_id) - data = copy.deepcopy(copr.__dict__) - data.pop("_sa_instance_state") + # only compare columns attrs, not relationships + column_attrs = {attr.key for attr in sa_inspect(copr).mapper.column_attrs} + data = {k: copy.deepcopy(v) for k, v in copr.__dict__.items() + if k in column_attrs} data.pop("latest_indexed_data_update") return data diff --git a/frontend/coprs_frontend/tests/test_forms.py b/frontend/coprs_frontend/tests/test_forms.py index 8c66f8c53..bfd247004 100644 --- a/frontend/coprs_frontend/tests/test_forms.py +++ b/frontend/coprs_frontend/tests/test_forms.py @@ -3,6 +3,7 @@ import pytest import wtforms import flask +from werkzeug.datastructures import MultiDict from tests.coprs_test_case import CoprsTestCase from coprs import app from coprs.forms import ( @@ -10,6 +11,7 @@ CoprFormFactory, CreateModuleForm, RpmValidator, + ProjectTagsFilter, REGEX_BOOTSTRAP_IMAGE, REGEX_CHROOT_DENYLIST, ) @@ -26,6 +28,78 @@ def test_empty_chroots(self): assert "At least one chroot" in form.errors[None][0] +class TestProjectTagsFilter: + def test_basic_comma_split(self): + """ + Comma-separated tag names are split into a list. + """ + assert ProjectTagsFilter()("cli, devtools") == ["cli", "devtools"] + + def test_ascii_drops_unicode_no_transliterate(self): + """ + Non-ASCII characters are dropped outright rather than transliterated. + """ + assert ProjectTagsFilter()("café") == ["caf"] + + def test_minimum_length_three(self): + """ + Tag names shorter than 3 characters are silently dropped. + """ + assert ProjectTagsFilter()("cli, ab, xyz") == ["cli", "xyz"] + + def test_dedup_preserves_order(self): + """ + Duplicate tag names are removed while preserving first-seen order. + """ + assert ProjectTagsFilter()("cli, devtools, cli") == ["cli", "devtools"] + + def test_empty_value(self): + """ + Empty or missing input produces an empty list rather than erroring. + """ + assert not ProjectTagsFilter()("") + assert not ProjectTagsFilter()(None) + + +class TestAdditionalProjectTagsField(CoprsTestCase): + """ + Regression tests for the AdditionalProjectTagsField.process_formdata() + fix: the API's `tags` input is preserved as a real list (unlike the web + form's single comma-separated string), so the field has to reconcile + both shapes into one before ProjectTagsFilter runs. + """ + + @pytest.mark.usefixtures("f_users", "f_db") + def test_multiple_formdata_values_all_preserved(self): + """ + Repeated `tags` form values (the API's list shape) are all joined into one comma-separated string. + """ + with app.test_request_context(): + flask.g.user = self.u2 + formdata = MultiDict([ + ("name", "foo"), + ("tags", "cli"), + ("tags", "monitoring"), + ("tags", "devtools"), + ]) + form = CoprFormFactory.create_form_cls()(formdata, meta={"csrf": False}) + assert form.tags.data == ["cli", "monitoring", "devtools"] + + @pytest.mark.usefixtures("f_users", "f_db") + def test_single_comma_string_still_works(self): + """ + A single comma-separated `tags` value (the web form's shape) is still handled correctly. + """ + with app.test_request_context(): + flask.g.user = self.u2 + formdata = MultiDict([ + ("name", "foo"), + ("tags", "cli, monitoring, devtools"), + ]) + form = CoprFormFactory.create_form_cls()(formdata, meta={"csrf": False}) + assert form.tags.data == ["cli", "monitoring", "devtools"] + + class TestPinnedCoprsForm(CoprsTestCase): @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") diff --git a/frontend/coprs_frontend/tests/test_logic/test_coprs_logic.py b/frontend/coprs_frontend/tests/test_logic/test_coprs_logic.py index b2f23659f..65bf5cf70 100644 --- a/frontend/coprs_frontend/tests/test_logic/test_coprs_logic.py +++ b/frontend/coprs_frontend/tests/test_logic/test_coprs_logic.py @@ -12,7 +12,8 @@ from coprs.forms import PinnedCoprsForm, ChrootForm, ModuleEnableNameValidator from coprs.logic.actions_logic import ActionsLogic from coprs.logic.coprs_logic import (CoprsLogic, CoprChrootsLogic, - PinnedCoprsLogic, CoprScoreLogic) + PinnedCoprsLogic, CoprScoreLogic, + ProjectTagsLogic) from coprs.logic.users_logic import UsersLogic from coprs.logic.complex_logic import ComplexLogic @@ -23,6 +24,7 @@ AccessRestricted, ConflictingRequest, InsufficientRightsException, + BadRequest, ) @@ -495,3 +497,98 @@ def test_search_by_attributes(self): packagename="world", ) assert set(result) == {self.c2} + + +class TestProjectTagsLogic(CoprsTestCase): + """ + Tests for ProjectTagsLogic. + """ + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_create_tag_is_get_or_create(self): + """ + Creating a tag with a name that already exists reuses the original row and creator. + """ + tag1 = ProjectTagsLogic.create_tag("cli", user=self.u1) + self.db.session.commit() + tag2 = ProjectTagsLogic.create_tag("cli", user=self.u2) + assert tag1.id == tag2.id + # the original creator is preserved, not overwritten by reuse + assert tag2.user == self.u1 + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_get_default_tags_filters_and_orders(self): + """ + get_default_tags() returns only is_default tags, alphabetically ordered. + """ + self.db.session.add_all([ + self.models.ProjectTag(name="zeta", is_default=True), + self.models.ProjectTag(name="acme", is_default=True), + self.models.ProjectTag(name="custom", is_default=False), + ]) + self.db.session.commit() + names = [t.name for t in ProjectTagsLogic.get_default_tags()] + assert names == ["acme", "zeta"] + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_set_copr_tags_caps_at_ten(self): + """ + Assigning more than 10 tags to a project raises BadRequest. + """ + names = [f"tag{i}" for i in range(11)] + with pytest.raises(BadRequest): + ProjectTagsLogic.set_copr_tags(self.c1, names, user=self.u1) + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_set_copr_tags_dedups(self): + """ + Duplicate tag names in the input are collapsed to one tag. + """ + ProjectTagsLogic.set_copr_tags(self.c1, ["cli", "cli", "devtools"], user=self.u1) + self.db.session.commit() + assert sorted(t.name for t in self.c1.project_tags) == ["cli", "devtools"] + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_reuses_existing_tag_across_projects(self): + """ + Two projects tagging the same name share the same underlying ProjectTag row. + """ + ProjectTagsLogic.set_copr_tags(self.c1, ["cli"], user=self.u1) + self.db.session.commit() + ProjectTagsLogic.set_copr_tags(self.c2, ["cli"], user=self.u2) + self.db.session.commit() + assert self.c1.project_tags[0].id == self.c2.project_tags[0].id + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_reedit_with_overlap_does_not_raise(self): + """ + Regression test: re-editing with an overlapping tag no + longer raises a UniqueViolation. + """ + ProjectTagsLogic.set_copr_tags(self.c1, ["cli", "devtools"], user=self.u1) + self.db.session.commit() + ProjectTagsLogic.set_copr_tags(self.c1, ["cli", "monitoring"], user=self.u1) + self.db.session.commit() + assert sorted(t.name for t in self.c1.project_tags) == ["cli", "monitoring"] + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_set_copr_tags_removes_all_when_empty(self): + """ + Passing an empty tag list clears every tag from the project. + """ + ProjectTagsLogic.set_copr_tags(self.c1, ["cli", "devtools"], user=self.u1) + self.db.session.commit() + ProjectTagsLogic.set_copr_tags(self.c1, [], user=self.u1) + self.db.session.commit() + assert not list(self.c1.project_tags) + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_get_multiple_by_tag_exact_match_only(self): + """ + Matches the exact tag name only, not similar-looking ones. + """ + ProjectTagsLogic.set_copr_tags(self.c1, ["cli"], user=self.u1) + ProjectTagsLogic.set_copr_tags(self.c3, ["cli-tool"], user=self.u2) + self.db.session.commit() + results = CoprsLogic.get_copr_projects_by_tag_name("cli").all() + assert [c.id for c in results] == [self.c1.id] diff --git a/frontend/coprs_frontend/tests/test_views/test_admin/test_admin_general.py b/frontend/coprs_frontend/tests/test_views/test_admin/test_admin_general.py index ca6cc5408..74ef416be 100644 --- a/frontend/coprs_frontend/tests/test_views/test_admin/test_admin_general.py +++ b/frontend/coprs_frontend/tests/test_views/test_admin/test_admin_general.py @@ -1,3 +1,4 @@ +# pylint: disable=unused-argument from tests.coprs_test_case import CoprsTestCase @@ -21,3 +22,62 @@ def test_admin_can_login(self, f_users, f_db): r = c.get("/admin/", follow_redirects=True) assert self.text_to_check in r.data.decode("utf-8") + + +class TestAdminTags(CoprsTestCase): + """ + Tests for the admin tag management routes + """ + + def _login_as_admin(self, c): + """ + Log the test client in as the admin user. + """ + with c.session_transaction() as s: + s["oidc"] = self.u1.username + + def test_create_tag_marks_default(self, f_users, f_db): + """ + Creating a tag via the admin route marks it as default immediately. + """ + with self.tc as c: + self._login_as_admin(c) + c.post("/admin/tags/create/", data={"name": "cli"}, + follow_redirects=True) + + tag = self.models.ProjectTag.query.filter_by(name="cli").first() + assert tag is not None + assert tag.is_default is True + + def test_rename_tag(self, f_users, f_db): + """ + Renaming a tag updates its name in place. + """ + tag = self.models.ProjectTag(name="cli", is_default=False, created_on=1) + self.db.session.add(tag) + self.db.session.commit() + + with self.tc as c: + self._login_as_admin(c) + c.post(f"/admin/tags/{tag.id}/rename/", + data={"name": "cli-tool"}, follow_redirects=True) + + self.db.session.expire(tag) + assert tag.name == "cli-tool" + + def test_rename_whole_input_as_one_name(self, f_users, f_db): + """ + Regression test: a comma in the rename input must not silently drop + part of the name (it isn't the multi-tag Create field). + """ + tag = self.models.ProjectTag(name="cli", is_default=False, created_on=1) + self.db.session.add(tag) + self.db.session.commit() + + with self.tc as c: + self._login_as_admin(c) + c.post(f"/admin/tags/{tag.id}/rename/", + data={"name": "hello, hi"}, follow_redirects=True) + + self.db.session.expire(tag) + assert tag.name == "hello-hi" diff --git a/frontend/coprs_frontend/tests/test_views/test_coprs_ns/test_coprs_general.py b/frontend/coprs_frontend/tests/test_views/test_coprs_ns/test_coprs_general.py index 81f394c75..79591f611 100644 --- a/frontend/coprs_frontend/tests/test_views/test_coprs_ns/test_coprs_general.py +++ b/frontend/coprs_frontend/tests/test_views/test_coprs_ns/test_coprs_general.py @@ -19,7 +19,7 @@ from coprs import app, cache, models from coprs.repos import generate_repo_name -from coprs.logic.coprs_logic import CoprsLogic, CoprDirsLogic +from coprs.logic.coprs_logic import CoprsLogic, CoprDirsLogic, ProjectTagsLogic from coprs.logic.actions_logic import ActionsLogic from commands.create_chroot import create_chroot_function @@ -1209,3 +1209,23 @@ def test_fedora_review_project(self): assert resp.status_code == 200 # error! error = parse_web_form_error(resp.data, variant="b") assert error == "Error in project config" + + +class TestCoprsByTag(CoprsTestCase): + """ + Tests for the /coprs/tags// browse-by-tag route. + """ + + @pytest.mark.usefixtures("f_users", "f_coprs", "f_db") + def test_by_tag_route_exact_match_only(self): + """ + The route must do an exact tag-name join filter, not a broad fulltext search. + """ + ProjectTagsLogic.set_copr_tags(self.c1, ["devtools"], user=self.u1) + self.db.session.commit() + + r = self.tc.get("/coprs/tags/devtools/") + assert r.status_code == 200 + content = r.data.decode("utf-8") + assert "foocopr" in content + assert "barcopr" not in content diff --git a/frontend/requirements.txt b/frontend/requirements.txt index 84bc11465..2892ab0fd 100644 --- a/frontend/requirements.txt +++ b/frontend/requirements.txt @@ -13,6 +13,7 @@ markupsafe pyLibravatar pydns # pyLibravatar uses this python-dateutil +python-slugify netaddr alembic redis diff --git a/python/copr/v3/proxies/project.py b/python/copr/v3/proxies/project.py index 1c7c2bd7b..b426af4cd 100644 --- a/python/copr/v3/proxies/project.py +++ b/python/copr/v3/proxies/project.py @@ -72,7 +72,7 @@ def add(self, ownername, projectname, chroots, description=None, instructions=No delete_after_days=None, multilib=False, module_hotfixes=False, bootstrap=None, bootstrap_image=None, isolation=None, follow_fedora_branching=True, fedora_review=None, appstream=False, runtime_dependencies=None, packit_forge_projects_allowed=None, - repo_priority=None, exist_ok=False, storage=None): + repo_priority=None, exist_ok=False, storage=None, tags=None): """ Create a project @@ -111,6 +111,7 @@ def add(self, ownername, projectname, chroots, description=None, instructions=No :param list packit_forge_projects_allowed: List of forge projects that will be allowed to build in the project via Packit :param str storage: Admin only - What storage should be used for this project + :param list tags: List of tags to attach to the project :return: Munch """ endpoint = "/project/add/{ownername}" @@ -144,6 +145,7 @@ def add(self, ownername, projectname, chroots, description=None, instructions=No "packit_forge_projects_allowed": packit_forge_projects_allowed, "repo_priority": repo_priority, "storage": storage, + "tags": tags, } _compat_use_bootstrap_container(data, use_bootstrap_container) @@ -163,7 +165,7 @@ def edit(self, ownername, projectname, chroots=None, description=None, instructi delete_after_days=None, multilib=None, module_hotfixes=None, bootstrap=None, bootstrap_image=None, isolation=None, follow_fedora_branching=None, fedora_review=None, appstream=None, runtime_dependencies=None, packit_forge_projects_allowed=None, - repo_priority=None): + repo_priority=None, tags=None): """ Edit a project @@ -200,6 +202,7 @@ def edit(self, ownername, projectname, chroots=None, description=None, instructi enabled together with this project repository. :param list packit_forge_projects_allowed: List of forge projects that will be allowed to build in the project via Packit + :param list tags: List of tags to attach to the project :return: Munch """ endpoint = "/project/edit/{ownername}/{projectname}" @@ -230,6 +233,7 @@ def edit(self, ownername, projectname, chroots=None, description=None, instructi "runtime_dependencies": runtime_dependencies, "packit_forge_projects_allowed": packit_forge_projects_allowed, "repo_priority": repo_priority, + "tags": tags, } _compat_use_bootstrap_container(data, use_bootstrap_container)