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
{{ nav_element("legalflags", "Legal Flags", "pficon pficon-flag", url_for('admin_ns.legal_flag')) }}
+ {{ nav_element("tags", "Tags", "fa fa-tag", url_for('admin_ns.tags')) }}
{{ nav_element("playground", "Playground", "fa fa-gamepad", url_for('admin_ns.playground')) }}
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
+
+
+
+
+
+
+ | Name |
+ Created by |
+ Created on |
+ |
+
+
+
+ {% for tag in tags %}
+
+ |
+
+ |
+
+ {% if tag.user %}
+ {{ tag.user.name }}
+ {% else %}
+ system
+ {% endif %}
+ |
+
+ {% if tag.created_on %}
+ {{ tag.created_on | time_ago() }} ago
+ {% else %}
+ unknown
+ {% endif %}
+ |
+
+
+ |
+
+ {% else %}
+
+ | No tags have been created yet. |
+
+ {% endfor %}
+
+
+
+{% 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 %}
+
+ {% 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 %}
+
+ {% 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 %}
+
+ -
+ Home
+
+ -
+ Tag: {{ tag_name }}
+
+
+{% 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)