Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMPROVEMENT] Add tags support for samples #858

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions mod_regression/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from flask import (Blueprint, abort, flash, g, jsonify, redirect, request,
url_for)
from sqlalchemy import and_, func
from sqlalchemy import and_

from decorators import template_renderer
from mod_auth.controllers import check_access_rights, login_required
Expand All @@ -13,9 +13,8 @@
from mod_regression.models import (Category, InputType, OutputType,
RegressionTest, RegressionTestOutput,
RegressionTestOutputFiles)
from mod_sample.models import Sample
from mod_test.models import (Fork, Test, TestPlatform, TestProgress,
TestResult, TestResultFile, TestStatus, TestType)
from mod_sample.models import Sample, Tag
from mod_test.models import TestResultFile
from utility import serve_file_download

mod_regression = Blueprint('regression', __name__)
Expand All @@ -37,7 +36,8 @@ def index():
"""Display all regression tests."""
return {
'tests': RegressionTest.query.all(),
'categories': Category.query.order_by(Category.name.asc()).all()
'categories': Category.query.order_by(Category.name.asc()).all(),
'tags': Tag.query.all()
}


Expand Down
48 changes: 40 additions & 8 deletions mod_sample/controllers.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
"""Logic to fetch sample information, uploading, editing, deleting sample."""

import json
import os
from operator import and_
from typing import Any, Callable, Dict, List, Optional, Tuple, Type
from typing import Any, Dict

import requests
from flask import Blueprint, g, make_response, redirect, request, url_for
from flask import Blueprint, g, redirect, request, url_for

from decorators import template_renderer
from exceptions import SampleNotFoundException
from mod_auth.controllers import check_access_rights, login_required
from mod_auth.models import Role
from mod_home.models import CCExtractorVersion, GeneralData
from mod_regression.models import RegressionTest
from mod_sample.forms import (DeleteAdditionalSampleForm, DeleteSampleForm,
EditSampleForm)
from mod_sample.forms import (AddTagForm, DeleteAdditionalSampleForm,
DeleteSampleForm, EditSampleForm)
from mod_sample.media_info_parser import (InvalidMediaInfoError,
MediaInfoFetcher)
from mod_sample.models import ExtraFile, ForbiddenExtension, Issue, Sample
from mod_sample.models import ExtraFile, Issue, Sample, Tag
from mod_test.models import Test, TestResult, TestResultFile
from mod_upload.models import Platform
from utility import serve_file_download
Expand Down Expand Up @@ -240,6 +238,34 @@ def download_sample_additional(sample_id, additional_id):
raise SampleNotFoundException(f"Sample with id {sample_id} not found")


@mod_sample.route('/add_tag', methods=['POST'])
@login_required
@check_access_rights([Role.admin])
def add_tag():
"""
Add tags on sample api, requires admin role.

:return: api response
:rtype: dict
"""
form = AddTagForm(request.form)

if form.validate_on_submit():
new_tag = Tag(form.name.data, form.description.data)
g.db.add(new_tag)
g.db.commit()

return {
'id': new_tag.id,
'name': new_tag.name,
'description': new_tag.description
}

return {
'errors': form.errors
}


@mod_sample.route('/edit/<sample_id>', methods=['GET', 'POST'])
@login_required
@check_access_rights([Role.admin])
Expand All @@ -258,9 +284,12 @@ def edit_sample(sample_id):

if sample is not None:
versions = CCExtractorVersion.query.all()
tags = Tag.query.all()
# Process or render form
form = EditSampleForm(request.form)
add_tag_form = AddTagForm(request.form)
form.version.choices = [(v.id, v.version) for v in versions]
form.tags.choices = [(tag.id, tag.name) for tag in tags]

if form.validate_on_submit():
# Store values
Expand All @@ -269,6 +298,7 @@ def edit_sample(sample_id):
upload.version_id = form.version.data
upload.platform = Platform.from_string(form.platform.data)
upload.parameters = form.parameters.data
sample.tags = list(Tag.query.filter(Tag.id.in_(form.tags.data)))
g.db.commit()
g.log.info(f"sample with id: {sample_id} updated")
return redirect(url_for('.sample_by_id', sample_id=sample.id))
Expand All @@ -279,10 +309,12 @@ def edit_sample(sample_id):
form.platform.data = sample.upload.platform.name
form.notes.data = sample.upload.notes
form.parameters.data = sample.upload.parameters
form.tags.data = [tag.id for tag in sample.tags]

return {
'sample': sample,
'form': form
'form': form,
'add_tag_form': add_tag_form
}

raise SampleNotFoundException(f"Sample with id {sample_id} not found")
Expand Down
28 changes: 27 additions & 1 deletion mod_sample/forms.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
"""Maintains forms related to sample CRUD operations."""

from flask_wtf import FlaskForm
from wtforms import SubmitField
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, ValidationError

from mod_upload.forms import CommonSampleForm

from .models import Tag


class EditSampleForm(CommonSampleForm):
"""Form to edit sample."""

submit = SubmitField('Update sample')


class AddTagForm(FlaskForm):
"""Form to add tags."""

name = StringField('Name', validators=[DataRequired(message="Tag name is required.")])
description = TextAreaField('Description')
submit = SubmitField('Add Tag')

@staticmethod
def validate_name(form, field) -> None:
"""
Validate tag name (case-insensitive).

:param form: form data
:type form: AddTagForm
:param field: field to validate
:type field: form field
:raises ValidationError: when the same tag already exists
"""
existing_tag = Tag.query.filter(Tag.name.ilike(field.data)).first()
if existing_tag:
raise ValidationError("Tag with the same name already exists.")


class DeleteSampleForm(FlaskForm):
"""Form to delete sample."""

Expand Down
34 changes: 30 additions & 4 deletions mod_sample/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"""Maintain database models regarding various sample, ExtraFile, ForbiddenExtension, ForbiddenMimeType, Issue."""

from datetime import datetime
from typing import Any, Dict, Type

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import (Column, DateTime, ForeignKey, Index, Integer, String,
Table, Text)
from sqlalchemy.orm import relationship

import database
from database import Base, DeclEnum
from database import Base


def get_extension(extension: str) -> str:
Expand All @@ -22,6 +21,32 @@ def get_extension(extension: str) -> str:
return ("." + extension) if len(extension) > 0 else ""


sample_tag_association = Table(
'sample_tag_association',
Base.metadata,
Column('sample_id', ForeignKey('sample.id'), primary_key=True, nullable=False),
Column('tag_id', ForeignKey('tag.id'), primary_key=True, nullable=False)
)


class Tag(Base):
"""Model to store tags."""

__tablename__ = 'tag'
__table_args__ = {'mysql_engine': 'InnoDB'}
id = Column(Integer, primary_key=True)
name = Column(String(64), unique=True, nullable=False)
description = Column(String(length=1024))
samples = relationship('Sample', secondary=sample_tag_association, back_populates='tags')

def __init__(self, name, description="") -> None:
self.name = name
self.description = description


tag_name_index = Index('tag_name_index', Tag.name)


class Sample(Base):
"""Model to store and manage sample."""

Expand All @@ -34,6 +59,7 @@ class Sample(Base):
extra_files = relationship('ExtraFile', back_populates='sample')
tests = relationship('RegressionTest', back_populates='sample')
upload = relationship('Upload', uselist=False, back_populates='sample')
tags = relationship('Tag', secondary=sample_tag_association, back_populates='samples')

def __init__(self, sha, extension, original_name) -> None:
"""
Expand Down
8 changes: 3 additions & 5 deletions mod_upload/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@

import mimetypes
import os
from typing import Any, Type

import magic
from flask_wtf import FlaskForm
from wtforms import FileField, SelectField, SubmitField, TextAreaField
from wtforms import (FileField, SelectField, SelectMultipleField, SubmitField,
TextAreaField)
from wtforms.validators import DataRequired, ValidationError

import mod_home.models
import mod_sample.models
import mod_upload.models
from mod_home.models import CCExtractorVersion
from mod_sample.models import ForbiddenExtension, ForbiddenMimeType
from mod_upload.models import Platform
Expand Down Expand Up @@ -75,6 +72,7 @@ class CommonSampleForm(FlaskForm):
coerce=str,
choices=[(p.value, p.description) for p in Platform]
)
tags = SelectMultipleField('Tags', coerce=int)
version = SelectField('Version', [DataRequired(message='Version is not selected')], coerce=int)

@staticmethod
Expand Down
22 changes: 22 additions & 0 deletions static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ html[dark=''] {
display: none;
}

label {
font-size: 16px;
}

label.success {
background-color: #e1faea;
padding: 0 5px;
Expand Down Expand Up @@ -539,3 +543,21 @@ input.toggle-round:checked + label:after {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

.tag:nth-child(even) {
border: 1px solid #c8e1ff;
}

.tag:nth-child(odd) {
color: #1779ba;
border: 1px solid #c8e1ff;
}

.tag {
display: inline-block;
background-color: var(--off-canvas-content-bg);
padding: 5px 10px;
border-radius: 4px;
margin-right: 5px;
font-size: 14px;
}
Loading