diff --git a/api_docs.yml b/api_docs.yml index 56faf05e..663564f7 100644 --- a/api_docs.yml +++ b/api_docs.yml @@ -2529,6 +2529,14 @@ paths: type: string project_type: type: string + tags_add: + type: array + items: + type: string + tags_remove: + type: array + items: + type: string responses: 200: description: "Success" diff --git a/app/controllers/project.py b/app/controllers/project.py index 6488a357..df3e7380 100644 --- a/app/controllers/project.py +++ b/app/controllers/project.py @@ -1,12 +1,14 @@ import datetime import json from types import SimpleNamespace +import uuid from app.helpers.cost_modal import CostModal from app.helpers.alias import create_alias from app.helpers.admin import is_authorised_project_user, is_owner_or_admin, is_current_or_admin, is_admin from app.helpers.role_search import has_role from app.helpers.activity_logger import log_activity from app.helpers.kube import create_kube_clients, delete_cluster_app, disable_project, enable_project, check_kube_error_code +from app.helpers.tags import add_tags_to_project, create_tags, remove_tags_from_project from app.models.billing_invoice import BillingInvoice from app.models.project_users import ProjectUser from app.models.user import User @@ -15,6 +17,7 @@ from app.schemas import ProjectSchema, AppSchema, ProjectUserSchema, ClusterSchema from app.helpers.decorators import admin_required import datetime +from app.schemas.tags import TagSchema from flask_restful import Resource, request from kubernetes import client from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt_claims @@ -57,6 +60,7 @@ def post(self): message=f'''project with name { validated_project_data["name"]} already exists''' ), 409 + try: validated_project_data['alias'] =\ create_alias(validated_project_data['name']) @@ -131,6 +135,9 @@ def post(self): ) project.users.append(new_role) + if validated_project_data['tags']: + tags = create_tags(validated_project_data['tags']) + saved = project.save() if not saved: @@ -491,7 +498,7 @@ def patch(self, project_id): current_user_roles = get_jwt_claims()['roles'] project_schema = ProjectSchema( - only=("name", "description", "organisation", "project_type"), partial=True) + only=("name", "description", "organisation", "project_type", "tags_add", "tags_remove"), partial=True) project_data = request.get_json() @@ -523,6 +530,15 @@ def patch(self, project_id): if not is_authorised_project_user(project, current_user_id, 'admin'): return dict(status='fail', message='unauthorised'), 403 + if validate_project_data.get('tags_add'): + add_tags_to_project( + validate_project_data['tags_add'], project) + validate_project_data.pop('tags_add', None) + if validate_project_data.get('tags_remove'): + remove_tags_from_project( + validate_project_data['tags_remove'], project) + validate_project_data.pop('tags_remove', None) + updated = Project.update(project, **validate_project_data) if not updated: diff --git a/app/controllers/tags.py b/app/controllers/tags.py index b841749c..5e087654 100644 --- a/app/controllers/tags.py +++ b/app/controllers/tags.py @@ -1,7 +1,7 @@ from flask_restful import Resource, request from flask_jwt_extended import jwt_required -from app.schemas.tags import TagSchema +from app.schemas.tags import TagSchema, TagsDetailSchema from app.models.tags import Tag from app.helpers.decorators import admin_required @@ -62,11 +62,11 @@ class TagsDetailView(Resource): @jwt_required def get(self, tag_id): - tag_id_schema = TagSchema() + tag_schema = TagsDetailSchema() tag = Tag.get_by_id(tag_id) - tags_data = tag_id_schema.dump(tag) + tags_data = tag_schema.dump(tag) return dict( status="success", diff --git a/app/helpers/tags.py b/app/helpers/tags.py new file mode 100644 index 00000000..afb2c9f0 --- /dev/null +++ b/app/helpers/tags.py @@ -0,0 +1,52 @@ +from app.models.tags import ProjectTag, Tag + + +def create_tags(tag_names): + """ + Create tags + """ + none_existing_tags = [] + existing_tags = [] + for tag in tag_names: + tag = tag.strip() + tag_rec = Tag.find_first(name=tag) + if not tag_rec: + none_existing_tags.append(Tag(name=tag)) + else: + existing_tags.append(tag_rec) + if none_existing_tags: + Tag.bulk_save(none_existing_tags) + + new_tags = [Tag.find_first(name=tag.name) + for tag in none_existing_tags] + if new_tags: + existing_tags.append(new_tags) + return existing_tags + + +def add_tags_to_project(tag_names, project): + tags = create_tags(tag_names) + project_tags = [] + for tag in tags: + project_tag = ProjectTag.find_first( + tag_id=tag.id, project_id=project.id) + if not project_tag: + project_tags.append(ProjectTag( + tag_id=tag.id, project_id=project.id)) + + if project_tags: + saved_tags = ProjectTag.bulk_save(project_tags) + if not saved_tags: + return False + return True + +def remove_tags_from_project(tag_names, project): + for tag in tag_names: + existing_tag = Tag.find_first(name=tag) + if not existing_tag: + continue + project_tag = ProjectTag.find_first( + tag_id=existing_tag.id, project_id=project.id) + if project_tag: + project_tag.delete() + return True \ No newline at end of file diff --git a/app/models/model_mixin.py b/app/models/model_mixin.py index dd59ebcf..b24ddb87 100644 --- a/app/models/model_mixin.py +++ b/app/models/model_mixin.py @@ -130,7 +130,7 @@ def check_exists(cls, **kwargs): if result > 0: return False - return False + return True @classmethod def get_by_id(cls, id): diff --git a/app/models/project.py b/app/models/project.py index 768faf6d..44224fb0 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -35,6 +35,7 @@ class Project(ModelMixin): deleted = db.Column(db.Boolean, default=False) disabled = db.Column(db.Boolean, default=False) admin_disabled = db.Column(db.Boolean, default=False) + tags = relationship('ProjectTag', back_populates='project') def is_followed_by(self, user): return any(follower.user_id == user.id for follower in self.followers) diff --git a/app/models/tags.py b/app/models/tags.py index 75b0b2a8..c9afb976 100644 --- a/app/models/tags.py +++ b/app/models/tags.py @@ -12,4 +12,25 @@ class Tag(ModelMixin): server_default=sa_text("uuid_generate_v4()")) name = db.Column(db.String) deleted = db.Column(db.Boolean, default=False) + is_super_tag = db.Column(db.Boolean, default=False) + projects = db.relationship("ProjectTag", back_populates="tag") date_created = db.Column(db.DateTime, default=db.func.current_timestamp()) + + def __repr__(self): + return f"" + + +class ProjectTag(ModelMixin): + __tablename__ = "project_tag" + + id = db.Column(UUID(as_uuid=True), primary_key=True, + server_default=sa_text("uuid_generate_v4()")) + project_id = db.Column(UUID(as_uuid=True), db.ForeignKey("project.id")) + tag_id = db.Column(UUID(as_uuid=True), db.ForeignKey("tag.id")) + + date_created = db.Column(db.DateTime, default=db.func.current_timestamp()) + project = db.relationship("Project", back_populates="tags") + tag = db.relationship("Tag", back_populates="projects") + + def __repr__(self): + return f"" diff --git a/app/schemas/project.py b/app/schemas/project.py index 68c26c5b..e0ef2e0f 100644 --- a/app/schemas/project.py +++ b/app/schemas/project.py @@ -5,6 +5,12 @@ from app.models.user import User +class ProjectListSchema(Schema): + id = fields.UUID(dump_only=True) + name = fields.String() + description = fields.String() + + class ProjectSchema(Schema): id = fields.UUID(dump_only=True) @@ -32,6 +38,9 @@ class ProjectSchema(Schema): admin_disabled = fields.Boolean(dump_only=True) prometheus_url = fields.Method("get_prometheus_url", dump_only=True) is_following = fields.Method("get_is_following", dump_only=True) + tags = fields.Nested("TagsProjectsSchema", many=True, dump_only=True) + tags_add = fields.List(fields.String, load_only=True) + tags_remove = fields.List(fields.String, load_only=True) def get_is_following(self, obj): # Assuming current_user is available in the view context diff --git a/app/schemas/tags.py b/app/schemas/tags.py index 63b530a1..4bd607c9 100644 --- a/app/schemas/tags.py +++ b/app/schemas/tags.py @@ -1,14 +1,29 @@ +from app.schemas.project import ProjectListSchema from marshmallow import Schema, fields, validate, pre_load class TagSchema(Schema): id = fields.UUID(dump_only=True) - name = fields.String(required=True, error_message={ - "required": "name is required"}, - validate=[ - validate.Regexp( - regex=r'^(?!\s*$)', error='name should be a valid string' - ), - ]) + name = fields.String(required=True) + is_super_tag = fields.Boolean() date_created = fields.Date(dump_only=True) + + +class TagsProjectsSchema(TagSchema): + name = fields.Method("get_name", dump_only=True) + id = fields.Method("get_id", dump_only=True) + is_super_tag = fields.Method("get_is_super_tag", dump_only=True) + + def get_id(self, obj): + return str(obj.tag.id) + + def get_name(self, obj): + return obj.tag.name + + def get_is_super_tag(self, obj): + return obj.tag.is_super_tag + + +class TagsDetailSchema(TagSchema): + projects = fields.Nested(ProjectListSchema, many=False, dump_only=True) diff --git a/migrations/versions/403631504272_.py b/migrations/versions/403631504272_.py new file mode 100644 index 00000000..65ad945f --- /dev/null +++ b/migrations/versions/403631504272_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 403631504272 +Revises: c7f9222b60b8 +Create Date: 2024-07-04 22:38:45.147122 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '403631504272' +down_revision = 'c7f9222b60b8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('project_tag', + sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('tag_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('tag', sa.Column('is_super_tag', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tag', 'is_super_tag') + op.drop_table('project_tag') + # ### end Alembic commands ###