diff --git a/.travis.yml b/.travis.yml index ef0c8be76..2688424aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ install: - pip install -U pip wheel - pip install -r requirements.txt - pip install -r test_requirements.txt + - make script: - ./runtests.sh after_success: diff --git a/funnel/__init__.py b/funnel/__init__.py index 1cff04e73..2fc7e600d 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -100,6 +100,16 @@ app.assets.register('css_leaflet', Bundle(assets.require('leaflet.css', 'leaflet-search.css'), output='css/leaflet.packed.css', filters='cssmin')) +app.assets.register('js_emojionearea', + Bundle(assets.require('!jquery.js', 'emojionearea-material.js'), + output='js/emojionearea.packed.js', filters='uglipyjs')) +app.assets.register('css_emojionearea', + Bundle(assets.require('emojionearea-material.css'), + output='css/emojionearea.packed.css', filters='cssmin')) +app.assets.register('js_sortable', + Bundle(assets.require('!jquery.js', 'jquery.ui.js', 'jquery.ui.sortable.touch.js'), + output='js/sortable.packed.js', filters='uglipyjs')) + baseframe.init_app(funnelapp, requires=['funnel'], ext_requires=[ 'pygments', 'toastr', 'baseframe-mui'], theme='mui') @@ -133,6 +143,15 @@ funnelapp.assets.register('css_leaflet', Bundle(assets.require('leaflet.css', 'leaflet-search.css'), output='css/leaflet.packed.css', filters='cssmin')) +funnelapp.assets.register('js_emojionearea', + Bundle(assets.require('!jquery.js', 'emojionearea-material.js'), + output='js/emojionearea.packed.js', filters='uglipyjs')) +funnelapp.assets.register('css_emojionearea', + Bundle(assets.require('emojionearea-material.css'), + output='css/emojionearea.packed.css', filters='cssmin')) +funnelapp.assets.register('js_sortable', + Bundle(assets.require('!jquery.js', 'jquery.ui.js', 'jquery.ui.sortable.touch.js'), + output='js/sortable.packed.js', filters='uglipyjs')) # FIXME: Hack for external build system generating relative /static URLs. # Fix this by generating absolute URLs to the static subdomain during build. diff --git a/funnel/assets/js/proposal.js b/funnel/assets/js/proposal.js index 819735053..29a0d64aa 100644 --- a/funnel/assets/js/proposal.js +++ b/funnel/assets/js/proposal.js @@ -101,9 +101,72 @@ export const Video = { }, }; +export const LabelsWidget = { + init() { + const Widget = this; + // On load, if the radio has been selected, then check mark the listwidget label + $('.listwidget input[type="radio"]').each(function() { + if(this.checked) { + $(this).siblings().find('.mui-form__label').addClass('checked'); + } + }); + + $('.listwidget .mui-form__label').click(function() { + if($(this).hasClass('checked')) { + $(this).removeClass('checked'); + $(this).siblings().find('input[type="radio"]').prop('checked', false); + Widget.updateLabels('', $(this).text().trim(), false); + } else { + $(this).addClass('checked'); + $(this).siblings().find('input[type="radio"]').first().click(); + } + }); + + // Add check mark to listwidget label + $('.listwidget input[type="radio"]').change(function() { + let label = $(this).parent().parent().prev('.mui-form__label'); + label.addClass('checked'); + let labelTxt = `${label.text()}: ${$(this).parent().find('label').text()}`.trim(); + let attr = label.text().trim(); + Widget.updateLabels(labelTxt, attr, this.checked); + }); + + $('.add-label-form input[type="checkbox"]').change(function() { + let labelTxt = $(this).parent('label').text().trim(); + Widget.updateLabels(labelTxt, labelTxt, this.checked); + }); + + // Open and close dropdown + $(document).on('click', function(event) { + if ($('#label-select')[0] === event.target || !$(event.target).parents('#label-dropdown').length) { + Widget.handleDropdown(); + } + }); + }, + handleDropdown() { + if($('#label-dropdown fieldset').hasClass('active')) { + $('#label-dropdown fieldset').removeClass('active'); + } else { + $('#label-dropdown fieldset').addClass('active'); + } + }, + updateLabels(label='', attr='', action=true) { + if(action) { + if(label !== attr) { + $(`.label[data-labeltxt="${attr}"]`).remove(); + } + let span = `${label}`; + $('#label-select').append(span); + } else { + $(`.label[data-labeltxt="${attr}"]`).remove(); + } + } +}; + $(() => { window. HasGeek.ProposalInit = function ({pageUrl, videoWrapper= '', videoUrl= ''}) { Comments.init(pageUrl); + LabelsWidget.init(); if (videoWrapper) { Video.embedIframe(videoWrapper, videoUrl); diff --git a/funnel/forms/__init__.py b/funnel/forms/__init__.py index 0070d0301..36a0e5144 100644 --- a/funnel/forms/__init__.py +++ b/funnel/forms/__init__.py @@ -9,3 +9,4 @@ from .session import * from .profile import * from .participant import * +from .label import * diff --git a/funnel/forms/label.py b/funnel/forms/label.py new file mode 100644 index 000000000..80623bbe1 --- /dev/null +++ b/funnel/forms/label.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from baseframe import __ +import baseframe.forms as forms + +__all__ = ['LabelForm', 'LabelOptionForm'] + + +class LabelForm(forms.Form): + name = forms.StringField("", widget=forms.HiddenInput(), validators=[forms.validators.Optional()]) + title = forms.StringField(__("Label"), + validators=[forms.validators.DataRequired(__(u"This can’t be empty")), forms.validators.Length(max=250)]) + icon_emoji = forms.StringField("") + required = forms.BooleanField(__("Make this label mandatory in proposal forms"), default=False, + description=__("If checked, proposers must select one of the options")) + restricted = forms.BooleanField(__("Restrict use of this label to editors"), default=False, + description=__("If checked, only editors and reviewers can apply this label on proposals")) + + +class LabelOptionForm(forms.Form): + name = forms.StringField("", widget=forms.HiddenInput(), validators=[forms.validators.Optional()]) + title = forms.StringField(__("Option"), + validators=[forms.validators.DataRequired(__(u"This can’t be empty")), forms.validators.Length(max=250)]) + icon_emoji = forms.StringField("") + seq = forms.IntegerField("", widget=forms.HiddenInput()) diff --git a/funnel/forms/proposal.py b/funnel/forms/proposal.py index 8cf784292..4b4424813 100644 --- a/funnel/forms/proposal.py +++ b/funnel/forms/proposal.py @@ -6,36 +6,81 @@ from baseframe.forms.sqlalchemy import QuerySelectField from ..models import Project, Profile -__all__ = ['TransferProposal', 'ProposalForm', 'ProposalTransitionForm', 'ProposalMoveForm'] +__all__ = ['TransferProposal', 'ProposalForm', 'ProposalTransitionForm', 'ProposalLabelsForm', + 'ProposalMoveForm', 'ProposalLabelsAdminForm'] + + +def proposal_label_form(project, proposal): + """ + Returns a label form for the given project and proposal. + """ + class ProposalLabelForm(forms.Form): + pass + + for label in project.labels: + if label.has_options and not label.archived and not label.restricted: + setattr(ProposalLabelForm, label.name, forms.RadioField( + label.form_label_text, + description=label.description, + validators=[forms.validators.DataRequired(__("Please select one"))] if label.required else [], + choices=[(option.name, option.title) for option in label.options if not option.archived] + )) + + return ProposalLabelForm(obj=proposal.formlabels if proposal else None, meta={'csrf': False}) + + +def proposal_label_admin_form(project, proposal): + """ + Returns a label form to use in admin panel for given project and proposal + """ + class ProposalLabelAdminForm(forms.Form): + pass + + for label in project.labels: + if not label.archived and (label.restricted or not label.has_options): + form_kwargs = {} + if label.has_options: + FieldType = forms.RadioField + form_kwargs['choices'] = [(option.name, option.title) for option in label.options if not option.archived] + else: + FieldType = forms.BooleanField + + setattr(ProposalLabelAdminForm, label.name, FieldType( + label.form_label_text, + description=label.description, + validators=[forms.validators.DataRequired(__("Please select one"))] if label.required else [], + **form_kwargs + )) + + return ProposalLabelAdminForm(obj=proposal.formlabels if proposal else None, meta={'csrf': False}) class TransferProposal(forms.Form): userid = forms.UserSelectField(__("Transfer to"), validators=[forms.validators.DataRequired()]) +class ProposalLabelsForm(forms.Form): + formlabels = forms.FormField(forms.Form, __("Labels")) + + def set_queries(self): + self.formlabels.form = proposal_label_form(project=self.edit_parent, proposal=self.edit_obj) + + +class ProposalLabelsAdminForm(forms.Form): + formlabels = forms.FormField(forms.Form, __("Labels")) + + def set_queries(self): + self.formlabels.form = proposal_label_admin_form(project=self.edit_parent, proposal=self.edit_obj) + + class ProposalForm(forms.Form): speaking = forms.RadioField(__("Are you speaking?"), coerce=int, choices=[(1, __(u"I will be speaking")), (0, __(u"I’m proposing a topic for someone to speak on"))]) title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()], description=__("The title of your session")) - section = QuerySelectField(__("Section"), get_label='title', validators=[forms.validators.DataRequired()], - widget=forms.ListWidget(prefix_label=False), option_widget=forms.RadioInput()) objective = forms.MarkdownField(__("Objective"), validators=[forms.validators.DataRequired()], description=__("What is the expected benefit for someone attending this?")) - session_type = forms.RadioField(__("Session type"), validators=[forms.validators.DataRequired()], choices=[ - ('Lecture', __("Lecture")), - ('Demo', __("Demo")), - ('Tutorial', __("Tutorial")), - ('Workshop', __("Workshop")), - ('Discussion', __("Discussion")), - ('Panel', __("Panel")), - ]) - technical_level = forms.RadioField(__("Technical level"), validators=[forms.validators.DataRequired()], choices=[ - ('Beginner', __("Beginner")), - ('Intermediate', __("Intermediate")), - ('Advanced', __("Advanced")), - ]) description = forms.MarkdownField(__("Description"), validators=[forms.validators.DataRequired()], description=__("A detailed description of the session")) requirements = forms.MarkdownField(__("Requirements"), @@ -63,6 +108,8 @@ class ProposalForm(forms.Form): location = forms.StringField(__("Your location"), validators=[forms.validators.DataRequired(), forms.validators.Length(max=80)], description=__("Your location, to help plan for your travel if required")) + formlabels = forms.FormField(forms.Form, __("Labels")) + def __init__(self, *args, **kwargs): super(ProposalForm, self).__init__(*args, **kwargs) project = kwargs.get('parent') @@ -75,6 +122,9 @@ def __init__(self, *args, **kwargs): if project.proposal_part_b.get('hint'): self.description.description = project.proposal_part_b.get('hint') + def set_queries(self): + self.formlabels.form = proposal_label_form(project=self.edit_parent, proposal=self.edit_obj) + class ProposalTransitionForm(forms.Form): transition = forms.SelectField(__("Status"), validators=[forms.validators.DataRequired()]) diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index f05ffa91a..1c1a3bd65 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -19,3 +19,4 @@ from .session import * from .user import * from .venue import * +from .label import * diff --git a/funnel/models/label.py b/funnel/models/label.py index 98a0ea266..46a6af017 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -1,67 +1,277 @@ # -*- coding: utf-8 -*- -from . import db, make_timestamp_columns, TimestampMixin, BaseScopedNameMixin -from .profile import Profile +from sqlalchemy.sql import case, exists +from sqlalchemy.ext.orderinglist import ordering_list +from sqlalchemy.ext.hybrid import hybrid_property + +from . import db, BaseScopedNameMixin from .project import Project from .proposal import Proposal -class Labelset(BaseScopedNameMixin, db.Model): - """ - A collection of labels, in checkbox mode (select multiple) or radio mode (select one). A profile can - contain multiple label sets and a Project can enable one or more sets to be used in Proposals. - """ - __tablename__ = 'labelset' +proposal_label = db.Table( + 'proposal_label', db.Model.metadata, + db.Column('proposal_id', None, db.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False, primary_key=True), + db.Column('label_id', None, db.ForeignKey('label.id', ondelete='CASCADE'), nullable=False, primary_key=True, index=True), + db.Column('created_at', db.DateTime, default=db.func.utcnow()) +) - profile_id = db.Column(None, db.ForeignKey('profile.id'), nullable=False) - profile = db.relationship(Profile, backref=db.backref('labelsets', cascade='all, delete-orphan')) - parent = db.synonym('profile') - radio_mode = db.Column(db.Boolean, nullable=False, default=False) +class Label(BaseScopedNameMixin, db.Model): + __tablename__ = 'label' - __table_args__ = (db.UniqueConstraint('profile_id', 'name'),) + project_id = db.Column(None, db.ForeignKey('project.id', ondelete='CASCADE'), nullable=False) + project = db.relationship(Project) # Backref is defined in the Project model with an ordering list + # `parent` is required for :meth:`~coaster.sqlalchemy.mixins.BaseScopedNameMixin.make_name()` + parent = db.synonym('project') - def __repr__(self): - return "" % (self.name, self.profile.name) + #: Parent label's id. Do not write to this column directly, as we don't have the ability to + #: validate the value within the app. Always use the :attr:`main_label` relationship. + main_label_id = db.Column( + 'main_label_id', + None, + db.ForeignKey('label.id', ondelete='CASCADE'), + index=True, + nullable=True + ) + # See https://docs.sqlalchemy.org/en/13/orm/self_referential.html + options = db.relationship( + 'Label', + backref=db.backref('main_label', remote_side='Label.id'), + order_by='Label.seq', + collection_class=ordering_list('seq', count_from=1) + ) + # TODO: Add sqlalchemy validator for `main_label` to ensure the parent's project matches. + # Ideally add a SQL post-update trigger as well (code is in coaster's add_primary_relationship) -proposal_label = db.Table( - 'proposal_label', db.Model.metadata, - *(make_timestamp_columns() + ( - db.Column('proposal_id', None, db.ForeignKey('proposal.id'), nullable=False, primary_key=True), - db.Column('label_id', None, db.ForeignKey('label.id'), nullable=False, primary_key=True) - ))) + #: Sequence number for this label, used in UI for ordering + seq = db.Column(db.Integer, nullable=False) + # A single-line description of this label, shown when picking labels (optional) + description = db.Column(db.UnicodeText, nullable=False, default=u"") -class Label(BaseScopedNameMixin, db.Model): - __tablename__ = 'label' + #: Icon for displaying in space-constrained UI. Contains one emoji symbol. + #: Since emoji can be composed from multiple symbols, there is no length + #: limit imposed here + icon_emoji = db.Column(db.UnicodeText, nullable=True) - labelset_id = db.Column(None, db.ForeignKey('labelset.id'), nullable=False) - labelset = db.relationship(Labelset) + #: Restricted mode specifies that this label may only be applied by someone with + #: an editorial role (TODO: name the role). If this label is a parent, it applies + #: to all its children + _restricted = db.Column('restricted', db.Boolean, nullable=False, default=False) + #: Required mode signals to UI that if this label is a parent, one of its + #: children must be mandatorily applied to the proposal. The value of this + #: field must be ignored if the label is not a parent + _required = db.Column('required', db.Boolean, nullable=False, default=False) + + #: Archived mode specifies that the label is no longer available for use + #: although all the previous records will stay in database. + _archived = db.Column('archived', db.Boolean, nullable=False, default=False) + + #: Proposals that this label is attached to proposals = db.relationship(Proposal, secondary=proposal_label, backref='labels') - __table_args__ = (db.UniqueConstraint('labelset_id', 'name'),) + __table_args__ = (db.UniqueConstraint('project_id', 'name'),) + + __roles__ = { + 'all': { + 'read': { + 'name', 'title', 'project_id', 'project', 'seq', + 'restricted', 'required', 'archived' + } + } + } + + @property + def title_for_name(self): + if self.main_label: + return u"%s/%s" % (self.main_label.title, self.title) + else: + return self.title + + @property + def form_label_text(self): + return self.icon_emoji + " " + self.title if self.icon_emoji is not None else self.title + + @property + def has_proposals(self): + if not self.has_options: + return bool(self.proposals) + else: + return any(bool(option.proposals) for option in self.options) + + @hybrid_property + def restricted(self): + return self.main_label._restricted if self.main_label else self._restricted + + @restricted.setter + def restricted(self, value): + if self.main_label: + raise ValueError("This flag must be set on the parent") + self._restricted = value + + @restricted.expression + def restricted(cls): + return case([ + (cls.main_label_id != None, db.select([Label._restricted]).where(Label.id == cls.main_label_id).as_scalar()) # NOQA + ], else_=cls._restricted) + + @hybrid_property + def archived(self): + return self._archived or (self.main_label._archived if self.main_label else False) + + @archived.setter + def archived(self, value): + self._archived = value + + @archived.expression + def archived(cls): + return case([ + (cls._archived == True, cls._archived), # NOQA + (cls.main_label_id != None, db.select([Label._archived]).where(Label.id == cls.main_label_id).as_scalar()) # NOQA + ], else_=cls._archived) + + @hybrid_property + def has_options(self): + return bool(self.options) + + @has_options.expression + def has_options(cls): + return exists().where(Label.main_label_id == cls.id) + + @property + def is_main_label(self): + return not self.main_label + + @hybrid_property + def required(self): + return self._required if self.has_options else False + + @required.setter + def required(self, value): + if value and not self.has_options: + raise ValueError("Labels without options cannot be mandatory") + self._required = value + + @property + def icon(self): + """ + Returns an icon for displaying the label in space-constrained UI. + If an emoji icon has been specified, use it. If not, create initials + from the title (up to 3). If the label is a single word, returns the + first three characters. + """ + result = self.icon_emoji + if not result: + result = ''.join(w[0] for w in self.title.strip().title().split(None, 2)) + if len(result) <= 1: + result = self.title.strip()[:3] + return result def __repr__(self): - return "