From f8944ad9a66d8ceedef0b78e4d26a80220d6b06a Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Tue, 18 Jun 2019 01:13:14 +0530 Subject: [PATCH] Adds search (for #438) (#442) --- funnel/__init__.py | 11 +- funnel/assets/js/app.js | 18 +- funnel/assets/js/search.js | 148 ++++++++ funnel/assets/webpack.config.js | 1 + funnel/models/__init__.py | 3 + funnel/models/commentvote.py | 47 ++- funnel/models/helper.py | 66 ---- funnel/models/helpers.py | 182 ++++++++++ funnel/models/label.py | 22 +- funnel/models/profile.py | 20 +- funnel/models/project.py | 34 +- funnel/models/proposal.py | 53 ++- funnel/models/session.py | 36 +- funnel/models/user.py | 8 + funnel/static/css/app.css | 333 ++++++++++++++---- funnel/static/sass/_card.scss | 36 +- funnel/static/sass/_header.scss | 280 ++++++++++++--- funnel/static/sass/_project.scss | 25 +- funnel/static/sass/_proposal.scss | 31 +- funnel/static/sass/_search.scss | 91 +++++ funnel/static/sass/app.scss | 1 + funnel/templates/comments.html.jinja2 | 12 +- funnel/templates/index.html.jinja2 | 2 +- funnel/templates/layout.html.jinja2 | 14 +- funnel/templates/macros.html.jinja2 | 4 +- funnel/templates/project.html.jinja2 | 8 +- funnel/templates/proposal_comment_email.md | 6 +- .../templates/proposal_comment_reply_email.md | 6 +- funnel/templates/proposals.html.jinja2 | 2 +- funnel/templates/search.html.jinja2 | 159 +++++++++ funnel/views/__init__.py | 2 +- funnel/views/search.py | 157 +++++++++ .../1829e53eba75_add_search_vectors.py | 166 +++++++++ 33 files changed, 1701 insertions(+), 283 deletions(-) create mode 100644 funnel/assets/js/search.js delete mode 100644 funnel/models/helper.py create mode 100644 funnel/models/helpers.py create mode 100644 funnel/static/sass/_search.scss create mode 100644 funnel/templates/search.html.jinja2 create mode 100644 funnel/views/search.py create mode 100644 migrations/versions/1829e53eba75_add_search_vectors.py diff --git a/funnel/__init__.py b/funnel/__init__.py index 2fc7e600d..424f85728 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -70,6 +70,10 @@ baseframe.init_app(app, requires=['funnel'], ext_requires=[ 'pygments', 'toastr', 'baseframe-mui'], theme='mui') +baseframe.init_app(funnelapp, requires=['funnel'], ext_requires=[ + 'pygments', 'toastr', 'baseframe-mui'], theme='mui') + +# Register JS and CSS assets on both apps app.assets.register('js_fullcalendar', Bundle(assets.require('!jquery.js', 'jquery.fullcalendar.js', 'spectrum.js', 'jquery.ui.sortable.touch.js'), output='js/fullcalendar.packed.js', filters='uglipyjs')) @@ -110,9 +114,6 @@ 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') funnelapp.assets.register('js_fullcalendar', Bundle(assets.require('!jquery.js', 'jquery.fullcalendar.js', 'spectrum.js', 'jquery.ui.sortable.touch.js'), output='js/fullcalendar.packed.js', filters='uglipyjs')) @@ -159,3 +160,7 @@ view_func=funnelapp.send_static_file, subdomain=None) funnelapp.add_url_rule('/static/', endpoint='static', view_func=funnelapp.send_static_file, subdomain='') + +# Database model loading (from Funnel or extensions) is complete. +# Configure database mappers now, before the process is forked for workers. +db.configure_mappers() diff --git a/funnel/assets/js/app.js b/funnel/assets/js/app.js index 0255aeb62..6f63c9936 100644 --- a/funnel/assets/js/app.js +++ b/funnel/assets/js/app.js @@ -11,7 +11,8 @@ $(() => { LazyloadImg.init('js-lazyload-img'); }; - if(document.querySelector('#page-navbar') || document.querySelector('.js-lazyload-img')) { + if(document.querySelector('#page-navbar') || document.querySelector('.js-lazyload-img') || + document.querySelector('.js-lazyload-results')) { if (!('IntersectionObserver' in global && 'IntersectionObserverEntry' in global && 'intersectionRatio' in IntersectionObserverEntry.prototype)) { @@ -46,4 +47,19 @@ $(() => { $(projectElemClass).removeClass('mui--hide'); $(this).addClass('mui--hide'); }); + + $('.js-search-show').on('click', function toggleSearchForm(event) { + event.preventDefault(); + $('.js-search-form').toggleClass('search-form--show'); + $('.js-search-field').focus(); + }); + + // Clicking outside close search form if open + $('body').on('click', function closeSearchForm(event) { + if($('.js-search-form').hasClass('search-form--show') && + !$(event.target).is('.js-search-field') && + !$.contains($('.js-search-show').parent('.header__nav-list__item')[0], event.target)) { + $('.js-search-form').removeClass('search-form--show'); + } + }); }); diff --git a/funnel/assets/js/search.js b/funnel/assets/js/search.js new file mode 100644 index 000000000..77311a7da --- /dev/null +++ b/funnel/assets/js/search.js @@ -0,0 +1,148 @@ +import Ractive from "ractive"; + +const Search = { + init(config) { + Ractive.DEBUG = false; + let widget = new Ractive({ + el: '#search-wrapper', + template: '#search-template', + data: { + tabs: config.counts, + results: '', + activeTab: '', + pagePath: window.location.pathname, + queryString: '', + defaultImage: config.defaultImage, + formatTime: function (date) { + let d = new Date(date); + return d.toLocaleTimeString('default', { hour: 'numeric', minute: 'numeric' }); + }, + formatDate: function (date) { + let d = new Date(date); + return d.toLocaleDateString('default', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); + }, + }, + getQueryString(paramName) { + let searchStr = window.location.search.substring(1).split('&'); + let queryString = searchStr.map((param) => { + let paramSplit = param.split('='); + if (paramSplit[0] === paramName) { + return paramSplit[1]; + } else { + return false; + } + }).filter(val => val && val !== ""); + return queryString[0]; + }, + updateTabContent(event, searchType) { + event.original.preventDefault(); + if(this.get('results.' + searchType)) { + let url = `${this.get('pagePath')}?q=${this.get('queryString')}&type=${searchType}`; + this.activateTab(searchType, '', url); + } else { + this.fetchResult(searchType); + } + }, + fetchResult(searchType, page=1) { + let url = `${this.get('pagePath')}?q=${this.get('queryString')}&type=${searchType}` + $.ajax({ + type: 'GET', + url: `${url}&page=${page}`, + timeout: 5000, + dataType: 'json', + success: function(data) { + widget.activateTab(searchType, data.results, url, page); + } + }); + }, + activateTab(searchType, result='', url='', page) { + if(result) { + if(page > 1) { + let existingResults = this.get('results.' + searchType); + let searchResults = []; + searchResults.push(...existingResults.items); + searchResults.push(...result.items); + result.items = searchResults + this.set('results.' + searchType, result); + } else { + this.set('results.' + searchType, result); + } + } + this.set('activeTab', searchType); + if (url) { + this.handleBrowserHistory(url); + } + this.updateMetaTags(searchType, url); + this.lazyoad(); + }, + handleBrowserHistory(url='') { + window.history.replaceState('', '', url); + }, + updateMetaTags: function(searchType, url='') { + let q = this.get('queryString'); + let { count } = this.get('results.' + searchType); + let title = `Search results: ${q}`; + let description = `${count} results found for "${q}"`; + + $('title').html(title); + $('meta[name=DC\\.title]').attr('content', title); + $('meta[property=og\\:title]').attr('content', title); + $('meta[name=description]').attr('content', description); + $('meta[property=og\\:description]').attr('content', description); + if(url) { + $('link[rel=canonical]').attr('href', url); + $('meta[property=og\\:url]').attr('content', url); + } + }, + lazyoad: function() { + let lazyLoader = document.querySelector('.js-lazy-loader'); + if(lazyLoader) { + this.handleObserver = this.handleObserver.bind(this); + + let observer = new IntersectionObserver( + this.handleObserver, + { + rootMargin: '0px', + threshold: 0.5 + }, + ); + observer.observe(lazyLoader); + } + }, + handleObserver(entries) { + entries.forEach(entry => { + if (entry.isIntersecting) { + let nextPage = entry.target.getAttribute('data-next-page'); + if(nextPage) { + this.fetchResult(this.get('activeTab'), nextPage); + } + } + return; + }); + }, + initTab() { + let queryString = this.getQueryString('q'); + this.set('queryString', queryString); + // Fill the search box with queryString + document.querySelector('.js-search-field').value = queryString; + + let searchType = this.getQueryString('type'); + if(searchType && config.results) { + this.activateTab(searchType, config.results); + } else { + searchType = this.get('tabs')[0]['type']; + this.fetchResult(searchType); + } + }, + onrender() { + this.initTab(); + } + }); + } +}; + +$(() => { + window.HasGeek.Search = function (config) { + Search.init(config); + } +}); diff --git a/funnel/assets/webpack.config.js b/funnel/assets/webpack.config.js index 33958f8a8..6353fcdfb 100644 --- a/funnel/assets/webpack.config.js +++ b/funnel/assets/webpack.config.js @@ -54,6 +54,7 @@ module.exports = { "scan_badge": path.resolve(__dirname, "js/scan_badge.js"), "scan_contact": path.resolve(__dirname, "js/scan_contact.js"), "contact": path.resolve(__dirname, "js/contact.js"), + "search": path.resolve(__dirname, "js/search.js"), }, output: { path: path.resolve(__dirname, "../static/build"), diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index a66ba3fb9..65720981c 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -1,13 +1,16 @@ # -*- coding: utf-8 -*- # flake8: noqa +from sqlalchemy_utils import TSVectorType from coaster.sqlalchemy import (TimestampMixin, UuidMixin, BaseMixin, BaseNameMixin, BaseScopedNameMixin, BaseScopedIdNameMixin, BaseIdNameMixin, MarkdownColumn, JsonDict, NoIdMixin, CoordinatesMixin, UrlType) from coaster.db import db + TimestampMixin.__with_timezone__ = True + from .commentvote import * from .contact_exchange import * from .draft import * diff --git a/funnel/models/commentvote.py b/funnel/models/commentvote.py index 0c942b8cf..011e35563 100644 --- a/funnel/models/commentvote.py +++ b/funnel/models/commentvote.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- -from . import db, BaseMixin, MarkdownColumn, UuidMixin -from .user import User from coaster.utils import LabeledEnum from coaster.sqlalchemy import cached, StateManager -from baseframe import __ +from baseframe import _, __ + +from . import db, BaseMixin, MarkdownColumn, UuidMixin, TSVectorType +from .user import User +from .helpers import add_search_trigger + __all__ = ['Voteset', 'Vote', 'Commentset', 'Comment'] @@ -85,6 +88,7 @@ def __init__(self, **kwargs): class Comment(UuidMixin, BaseMixin, db.Model): __tablename__ = 'comment' + user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True) user = db.relationship(User, primaryjoin=user_id == User.id, backref=db.backref('comments', lazy='dynamic', cascade="all, delete-orphan")) @@ -106,10 +110,44 @@ class Comment(UuidMixin, BaseMixin, db.Model): edited_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) + __roles__ = { + 'all': { + 'read': {'absolute_url', 'created_at', 'edited_at', 'user', 'title', 'message'} + } + } + + search_vector = db.deferred(db.Column( + TSVectorType( + 'message_text', + weights={'message_text': 'A'}, + regconfig='english', + hltext=lambda: Comment.message_html, + ), + nullable=False)) + + __table_args__ = ( + db.Index('ix_comment_search_vector', 'search_vector', postgresql_using='gin'), + ) + def __init__(self, **kwargs): super(Comment, self).__init__(**kwargs) self.voteset = Voteset(type=SET_TYPE.COMMENT) + @property + def absolute_url(self): + if self.commentset.proposal: + return self.commentset.proposal.absolute_url + '#c' + self.suuid + + @property + def title(self): + obj = self.commentset.proposal + if obj: + return _("{user} commented on {obj}").format( + user=self.user.pickername, + obj=self.commentset.proposal.title) + else: + return _("{user} commented").format(user=self.user.pickername) + @state.transition(None, state.DELETED) def delete(self): """ @@ -142,3 +180,6 @@ def permissions(self, user, inherited=None): 'delete_comment' ]) return perms + + +add_search_trigger(Comment, 'search_vector') diff --git a/funnel/models/helper.py b/funnel/models/helper.py deleted file mode 100644 index fc17f1bfe..000000000 --- a/funnel/models/helper.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- - -RESERVED_NAMES = set([ - '_baseframe', - 'admin', - 'api', - 'app', - 'apps', - 'auth', - 'blog', - 'boxoffice', - 'brand', - 'brands', - 'client', - 'clients', - 'confirm', - 'delete', - 'edit', - 'email' - 'emails' - 'embed', - 'event', - 'events', - 'ftp', - 'funnel', - 'funnels', - 'hacknight', - 'hacknights', - 'hasjob', - 'hgtv', - 'imap', - 'kharcha', - 'login', - 'logout', - 'new', - 'news', - 'organization', - 'organizations', - 'org', - 'orgs', - 'pop', - 'pop3', - 'post', - 'posts', - 'profile', - 'profiles', - 'project', - 'projects', - 'proposal', - 'proposals', - 'register', - 'reset', - 'smtp', - 'static', - 'ticket', - 'tickets', - 'token', - 'tokens', - 'venue', - 'venues', - 'video', - 'videos', - 'workshop', - 'workshops', - 'www', - ]) diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py new file mode 100644 index 000000000..163c7dcaf --- /dev/null +++ b/funnel/models/helpers.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals +from textwrap import dedent +from sqlalchemy import event, DDL +from sqlalchemy.dialects.postgresql.base import RESERVED_WORDS as POSTGRESQL_RESERVED_WORDS + +__all__ = ['RESERVED_NAMES', 'add_search_trigger'] + + +RESERVED_NAMES = set([ + '_baseframe', + 'admin', + 'api', + 'app', + 'apps', + 'auth', + 'blog', + 'boxoffice', + 'brand', + 'brands', + 'client', + 'clients', + 'confirm', + 'delete', + 'edit', + 'email' + 'emails' + 'embed', + 'event', + 'events', + 'ftp', + 'funnel', + 'funnels', + 'hacknight', + 'hacknights', + 'hasjob', + 'hgtv', + 'imap', + 'kharcha', + 'login', + 'logout', + 'new', + 'news', + 'organization', + 'organizations', + 'org', + 'orgs', + 'pop', + 'pop3', + 'post', + 'posts', + 'profile', + 'profiles', + 'project', + 'projects', + 'proposal', + 'proposals', + 'register', + 'reset', + 'search', + 'smtp', + 'static', + 'ticket', + 'tickets', + 'token', + 'tokens', + 'venue', + 'venues', + 'video', + 'videos', + 'workshop', + 'workshops', + 'www', + ]) + + +def pgquote(identifier): + """ + Adds double quotes to the given identifier if required (PostgreSQL only). + """ + return ('"%s"' % identifier) if identifier in POSTGRESQL_RESERVED_WORDS else identifier + + +def add_search_trigger(model, column_name): + """ + Adds a search trigger and returns SQL for use in migrations. Typical use:: + + class MyModel(db.Model): + ... + search_vector = db.deferred(db.Column( + TSVectorType('name', 'title', weights={'name': 'A', 'title': 'B'}, regconfig='english'), + nullable=False)) + + __table_args__ = ( + db.Index('ix_mymodel_search_vector', 'search_vector', postgresql_using='gin'), + ) + + add_search_trigger(MyModel, 'search_vector') + + To extract the SQL required in a migration: + + $ python manage.py shell + >>> print(models.add_search_trigger(models.MyModel, 'search_vector')['trigger']) + + Available keys: ``update``, ``trigger`` (for upgrades) and ``drop`` (for downgrades). + + :param model: Model class + :param str column_name: Name of the tsvector column in the model + """ + column = getattr(model, column_name) + function_name = model.__tablename__ + '_' + column_name + '_update' + trigger_name = model.__tablename__ + '_' + column_name + '_trigger' + weights = column.type.options.get('weights', {}) + regconfig = column.type.options.get('regconfig', 'english') + + trigger_fields = [] + update_fields = [] + + for col in column.type.columns: + texpr = "to_tsvector('{regconfig}', COALESCE(NEW.{col}, ''))".format( + regconfig=regconfig, col=pgquote(col)) + uexpr = "to_tsvector('{regconfig}', COALESCE({col}, ''))".format( + regconfig=regconfig, col=pgquote(col)) + if col in weights: + texpr = "setweight({expr}, '{weight}')".format(expr=texpr, weight=weights[col]) + uexpr = "setweight({expr}, '{weight}')".format(expr=uexpr, weight=weights[col]) + trigger_fields.append(texpr) + update_fields.append(uexpr) + + trigger_expr = ' || '.join(trigger_fields) + update_expr = ' || '.join(update_fields) + + trigger_function = dedent( + ''' + CREATE FUNCTION {function_name}() RETURNS trigger AS $$ + BEGIN + NEW.{column_name} := {trigger_expr}; + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER {trigger_name} BEFORE INSERT OR UPDATE ON {table_name} + FOR EACH ROW EXECUTE PROCEDURE {function_name}(); + '''.format( + function_name=pgquote(function_name), + column_name=pgquote(column_name), + trigger_expr=trigger_expr, + trigger_name=pgquote(trigger_name), + table_name=pgquote(model.__tablename__), + )) + + update_statement = dedent( + ''' + UPDATE {table_name} SET {column_name} = {update_expr}; + '''.format( + table_name=pgquote(model.__tablename__), + column_name=pgquote(column_name), + update_expr=update_expr, + )) + + drop_statement = dedent( + ''' + DROP TRIGGER {trigger_name} ON {table_name}; + DROP FUNCTION {function_name}(); + '''.format( + trigger_name=pgquote(trigger_name), + table_name=pgquote(model.__tablename__), + function_name=pgquote(function_name), + )) + + event.listen(model.__table__, 'after_create', + DDL(trigger_function).execute_if(dialect='postgresql')) + + event.listen(model.__table__, 'before_drop', + DDL(drop_statement).execute_if(dialect='postgresql')) + + return { + 'trigger': trigger_function, + 'update': update_statement, + 'drop': drop_statement, + } diff --git a/funnel/models/label.py b/funnel/models/label.py index 1de82f98b..ffec96717 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -4,9 +4,10 @@ from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.hybrid import hybrid_property -from . import db, BaseScopedNameMixin +from . import db, BaseScopedNameMixin, TSVectorType from .project import Project from .proposal import Proposal +from .helpers import add_search_trigger proposal_label = db.Table( @@ -70,15 +71,27 @@ class Label(BaseScopedNameMixin, db.Model): #: although all the previous records will stay in database. _archived = db.Column('archived', db.Boolean, nullable=False, default=False) + search_vector = db.deferred(db.Column( + TSVectorType( + 'name', 'title', 'description', + weights={'name': 'A', 'title': 'A', 'description': 'B'}, + regconfig='english', + hltext=lambda: db.func.concat_ws(' / ', Label.title, Label.description), + ), + nullable=False)) + #: Proposals that this label is attached to proposals = db.relationship(Proposal, secondary=proposal_label, backref='labels') - __table_args__ = (db.UniqueConstraint('project_id', 'name'),) + __table_args__ = ( + db.UniqueConstraint('project_id', 'name'), + db.Index('ix_label_search_vector', 'search_vector', postgresql_using='gin'), + ) __roles__ = { 'all': { 'read': { - 'name', 'title', 'project_id', 'project', 'seq', + 'name', 'title', 'project', 'seq', 'restricted', 'required', 'archived' } } @@ -205,6 +218,9 @@ def remove_from(self, proposal): proposal.labels.remove(self) +add_search_trigger(Label, 'search_vector') + + class ProposalLabelProxyWrapper(object): def __init__(self, obj): object.__setattr__(self, '_obj', obj) diff --git a/funnel/models/profile.py b/funnel/models/profile.py index 9f5c1fd66..33327b2d9 100644 --- a/funnel/models/profile.py +++ b/funnel/models/profile.py @@ -2,9 +2,9 @@ from flask_lastuser.sqlalchemy import ProfileBase -from . import MarkdownColumn, UuidMixin, UrlType, db +from . import MarkdownColumn, UuidMixin, UrlType, TSVectorType, db from .user import UseridMixin, Team -from .helper import RESERVED_NAMES +from .helpers import RESERVED_NAMES, add_search_trigger __all__ = ['Profile'] @@ -21,10 +21,23 @@ class Profile(UseridMixin, UuidMixin, ProfileBase, db.Model): #: Legacy profiles are available via funnelapp, non-legacy in the main app legacy = db.Column(db.Boolean, default=False, nullable=False) + search_vector = db.deferred(db.Column( + TSVectorType( + 'name', 'title', 'description_text', + weights={'name': 'A', 'title': 'A', 'description_text': 'B'}, + regconfig='english', + hltext=lambda: db.func.concat_ws(' / ', Profile.title, Profile.description_html), + ), + nullable=False)) + teams = db.relationship( Team, primaryjoin='Profile.uuid == foreign(Team.org_uuid)', backref='profile', lazy='dynamic') + __table_args__ = ( + db.Index('ix_profile_search_vector', 'search_vector', postgresql_using='gin'), + ) + __roles__ = { 'all': { 'read': { @@ -50,3 +63,6 @@ def roles_for(self, actor=None, anchors=()): if actor is not None and self.admin_team in actor.teams: roles.add('admin') return roles + + +add_search_trigger(Profile, 'search_vector') diff --git a/funnel/models/project.py b/funnel/models/project.py index 6897a66b5..24df407d0 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -16,11 +16,12 @@ from coaster.utils import LabeledEnum, utcnow, valid_username from ..util import geonameid_from_location -from . import BaseScopedNameMixin, JsonDict, MarkdownColumn, TimestampMixin, UuidMixin, UrlType, db +from . import (BaseScopedNameMixin, JsonDict, MarkdownColumn, TimestampMixin, UuidMixin, UrlType, + TSVectorType, db) from .user import Team, User from .profile import Profile from .commentvote import Commentset, SET_TYPE, Voteset -from .helper import RESERVED_NAMES +from .helpers import RESERVED_NAMES, add_search_trigger __all__ = ['Project', 'ProjectRedirect', 'ProjectLocation'] @@ -135,6 +136,20 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): #: project editors or profile admins. featured = db.Column(db.Boolean, default=False, nullable=False) + search_vector = db.deferred(db.Column( + TSVectorType( + 'name', 'title', 'description_text', 'instructions_text', 'location', + weights={ + 'name': 'A', 'title': 'A', 'description_text': 'B', 'instructions_text': 'B', + 'location': 'C' + }, + regconfig='english', + hltext=lambda: db.func.concat_ws( + ' / ', Project.title, Project.location, + Project.description_html, Project.instructions_html), + ), + nullable=False)) + venues = db.relationship('Venue', cascade='all, delete-orphan', order_by='Venue.seq', collection_class=ordering_list('seq', count_from=1)) labels = db.relationship('Label', cascade='all, delete-orphan', @@ -152,13 +167,16 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): 'Session', order_by="Session.start.asc()", primaryjoin='and_(Session.project_id == Project.id, Session.scheduled != True)') - __table_args__ = (db.UniqueConstraint('profile_id', 'name'),) + __table_args__ = ( + db.UniqueConstraint('profile_id', 'name'), + db.Index('ix_project_search_vector', 'search_vector', postgresql_using='gin'), + ) __roles__ = { 'all': { 'read': { 'id', 'name', 'title', 'datelocation', 'timezone', 'date', 'date_upto', 'url_json', - '_state', 'website', 'bg_image', 'bg_color', 'explore_url', 'tagline', 'absolute_url', + 'website', 'bg_image', 'bg_color', 'explore_url', 'tagline', 'absolute_url', 'location', 'calendar_weeks' }, 'call': { @@ -233,12 +251,11 @@ def datelocation(self): lambda project: db.session.query(project.sessions.exists()).scalar(), label=('has_sessions', __("Has Sessions"))) cfp_state.add_conditional_state('PRIVATE_DRAFT', cfp_state.NONE, - lambda project: project.instructions.html != '', - lambda project: project.__table__.c.instructions_html != '', + lambda project: project.instructions_html != '', label=('private_draft', __("Private draft"))) cfp_state.add_conditional_state('DRAFT', cfp_state.PUBLIC, lambda project: project.cfp_start_at is None, - lambda project: project.__table__.c.cfp_start_at == None, # NOQA + lambda project: project.cfp_start_at == None, # NOQA label=('draft', __("Draft"))) cfp_state.add_conditional_state('UPCOMING', cfp_state.PUBLIC, lambda project: project.cfp_start_at is not None and project.cfp_start_at > utcnow(), @@ -495,6 +512,9 @@ def roles_for(self, actor=None, anchors=()): return roles +add_search_trigger(Project, 'search_vector') + + Profile.listed_projects = db.relationship( Project, lazy='dynamic', primaryjoin=db.and_( diff --git a/funnel/models/proposal.py b/funnel/models/proposal.py index 087b037e6..86cf3cba2 100644 --- a/funnel/models/proposal.py +++ b/funnel/models/proposal.py @@ -1,15 +1,20 @@ # -*- coding: utf-8 -*- -from . import db, TimestampMixin, UuidMixin, BaseScopedIdNameMixin, MarkdownColumn, CoordinatesMixin, UrlType -from .user import User -from .project import Project -from .commentvote import Commentset, Voteset, SET_TYPE +from werkzeug.utils import cached_property +from sqlalchemy.ext.hybrid import hybrid_property + from coaster.utils import LabeledEnum from coaster.sqlalchemy import SqlSplitIdComparator, StateManager, with_roles from baseframe import __ -from sqlalchemy.ext.hybrid import hybrid_property -from werkzeug.utils import cached_property + from ..util import geonameid_from_location +from . import (TimestampMixin, UuidMixin, BaseScopedIdNameMixin, MarkdownColumn, + CoordinatesMixin, UrlType, TSVectorType, db) +from .user import User +from .project import Project +from .commentvote import Commentset, Voteset, SET_TYPE +from .helpers import add_search_trigger + __all__ = ['PROPOSAL_STATE', 'Proposal', 'ProposalRedirect'] @@ -86,19 +91,44 @@ class Proposal(UuidMixin, BaseScopedIdNameMixin, CoordinatesMixin, db.Model): commentset_id = db.Column(None, db.ForeignKey('commentset.id'), nullable=False) commentset = db.relationship(Commentset, uselist=False, lazy='joined', - cascade='all, delete-orphan', single_parent=True) + cascade='all, delete-orphan', single_parent=True, + backref=db.backref('proposal', uselist=False)) edited_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) location = db.Column(db.Unicode(80), nullable=False) - __table_args__ = (db.UniqueConstraint('project_id', 'url_id'),) + search_vector = db.deferred(db.Column( + TSVectorType( + 'title', 'abstract_text', 'outline_text', 'requirements_text', 'slides', + 'preview_video', 'links', 'bio_text', + weights={ + 'title': 'A', + 'abstract_text': 'B', + 'outline_text': 'B', + 'requirements_text': 'B', + 'slides': 'B', + 'preview_video': 'C', + 'links': 'B', + 'bio_text': 'B', + }, + regconfig='english', + hltext=lambda: db.func.concat_ws(' / ', Proposal.title, Proposal.abstract_html, + Proposal.outline_html, Proposal.requirements_html, + Proposal.links, Proposal.bio_html), + ), + nullable=False)) + + __table_args__ = ( + db.UniqueConstraint('project_id', 'url_id'), + db.Index('ix_proposal_search_vector', 'search_vector', postgresql_using='gin'), + ) __roles__ = { 'all': { 'read': { - 'title', 'speaker', 'speaking', 'bio', 'abstract', + 'title', 'user', 'speaker', 'speaking', 'bio', 'abstract', 'outline', 'requirements', 'slides', 'preview_video', 'links', 'location', - 'latitude', 'longitude', 'coordinates' + 'latitude', 'longitude', 'coordinates', 'session', 'project', }, 'call': { 'url_for' @@ -292,6 +322,9 @@ def roles_for(self, actor=None, anchors=()): return roles +add_search_trigger(Proposal, 'search_vector') + + class ProposalRedirect(TimestampMixin, db.Model): __tablename__ = 'proposal_redirect' diff --git a/funnel/models/session.py b/funnel/models/session.py index 8de69e1c4..6ef192983 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- from sqlalchemy.ext.hybrid import hybrid_property -from . import db, UuidMixin, BaseScopedIdNameMixin, MarkdownColumn, UrlType +from . import db, UuidMixin, BaseScopedIdNameMixin, MarkdownColumn, UrlType, TSVectorType from .project import Project from .proposal import Proposal from .venue import VenueRoom +from .helpers import add_search_trigger __all__ = ['Session'] @@ -31,13 +32,41 @@ class Session(UuidMixin, BaseScopedIdNameMixin, db.Model): featured = db.Column(db.Boolean, default=False, nullable=False) banner_image_url = db.Column(UrlType, nullable=True) + search_vector = db.deferred(db.Column( + TSVectorType( + 'title', 'description_text', 'speaker_bio_text', 'speaker', + weights={ + 'title': 'A', 'description_text': 'B', 'speaker_bio_text': 'B', 'speaker': 'A' + }, + regconfig='english', + hltext=lambda: db.func.concat_ws(' / ', Session.title, Session.speaker, + Session.description_html, Session.speaker_bio_html), + ), + nullable=False)) + __table_args__ = ( db.UniqueConstraint('project_id', 'url_id'), db.CheckConstraint( '("start" IS NULL AND "end" IS NULL) OR ("start" IS NOT NULL AND "end" IS NOT NULL)', - 'session_start_end_check') + 'session_start_end_check'), + db.Index('ix_session_search_vector', 'search_vector', postgresql_using='gin'), ) + __roles__ = { + 'all': { + 'read': { + 'title', 'project', 'speaker', 'user', 'featured', + 'description', 'speaker_bio', 'start', 'end', 'venue_room', 'is_break', + 'banner_image_url', + } + } + } + + @hybrid_property + def user(self): + if self.proposal: + return self.proposal.speaker + @hybrid_property def scheduled(self): # A session is scheduled only when both start and end fields have a value @@ -63,6 +92,9 @@ def make_unscheduled(self): self.end = None +add_search_trigger(Session, 'search_vector') + + # Project schedule column expressions # Guide: https://docs.sqlalchemy.org/en/13/orm/mapped_sql_expr.html#using-column-property Project.schedule_start_at = db.column_property( diff --git a/funnel/models/user.py b/funnel/models/user.py index db4b348db..db3faf49f 100644 --- a/funnel/models/user.py +++ b/funnel/models/user.py @@ -37,6 +37,14 @@ def userid(cls): class User(UseridMixin, UuidMixin, UserBase2, db.Model): __tablename__ = 'user' + __roles__ = { + 'all': { + 'read': { + 'username', 'fullname', 'avatar', + } + } + } + class Team(UseridMixin, UuidMixin, TeamBase, db.Model): __tablename__ = 'team' diff --git a/funnel/static/css/app.css b/funnel/static/css/app.css index 84936e75f..52b4fe7bd 100644 --- a/funnel/static/css/app.css +++ b/funnel/static/css/app.css @@ -1,3 +1,4 @@ +@charset "UTF-8"; .zero-bottom-margin { margin-bottom: 0; } @@ -231,43 +232,161 @@ html.touch .collapsible__body--disable { .hg-app .header--fixed { bottom: 0; } -.hg-app .header__site-title { - width: calc(30% - 15px); +.hg-app .header--fixed .header__nav { + padding: 0 15px; + justify-content: space-between; +} +.hg-app .header--fixed .header__nav .header__site-title { + padding: 0; } -.hg-app .header__site-title__logo { - height: 28px; +.hg-app .header--fixed .header__nav .header__site-title .header__site-title__logo { + height: 30px; } -.hg-app .header__right-nav { - width: calc(70% - 15px); +.hg-app .header--fixed .header__nav .header__site-title .search-form { + position: fixed; + bottom: 50px; + width: 100%; + left: 0; + box-shadow: 0 1px 3px rgba(158, 158, 158, 0.12), 0 1px 2px rgba(158, 158, 158, 0.24); + display: none; } -.hg-app .header__button { +.hg-app .header--fixed .header__nav .header__site-title .search-form .search-form__field { + width: 100%; + background-color: #eff2f6; + border: none; + padding: 15px 30px; + color: #1F2D3D; + font-size: 16px; +} +.hg-app .header--fixed .header__nav .header__site-title .search-form--show { + display: inline-block; +} +.hg-app .header--fixed .header__nav .header__right-nav { + width: 100%; + padding: 0; +} +.hg-app .header--fixed .header__nav .header__right-nav .header__nav-list { + justify-content: space-between; +} +.hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__nav-links .header__nav-links__icon { + font-size: 18px; +} +.hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__button { color: #fff; line-height: 30.6px; margin: 0; } +.hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__button .header__nav-links__text { + margin-left: 0 !important; +} .hg-app .content-wrapper { padding-top: 0; } -@media (min-width: 1200px) { +@media (min-width: 768px) { .hg-app .header--fixed { bottom: unset; top: 0; } - .hg-app .header__site-title__logo { + .hg-app .header--fixed .header__nav .header__site-title { + width: 100%; + margin-right: 0; + } + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title { + display: flex; + align-items: center; + } + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .header__site-title__logo { height: 35px; + margin-right: 15px; + position: relative; + top: -2px; + } + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .search-form { + position: relative; + bottom: 0; + display: flex; + width: 400px; + margin: auto; + box-shadow: none; + } + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .search-form .search-form__field { + border-radius: 2px; + padding: 8px 10px; + } + .hg-app .header--fixed .header__nav .header__right-nav { + width: auto; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list { + justify-content: flex-end; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item:first-child { + display: none; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__nav-links .header__nav-links__icon { + display: inline; + font-size: 20px; + top: -2px; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__nav-links .header__nav-links__text { + display: inline; + font-size: 18px; + margin-left: 8px; } .hg-app .content-wrapper { padding-top: 52px; } - - .no-sticky-header .header--fixed { + .hg-app .no-sticky-header .header--fixed { position: relative; } - .no-sticky-header .content-wrapper { + .hg-app .no-sticky-header .content-wrapper { padding-top: 0; } } +@media (min-width: 1200px) { + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .search-form { + width: 400px; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .hg-app .header .mui-container { + max-width: 100%; + } +} +@media only screen and (max-device-width: 767px) and (orientation: landscape) { + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .header__site-title__logo { + height: 25px; + } + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .search-form { + bottom: 35px; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__nav-list__item__text { + display: inline-block; + margin-left: 8px; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__button { + line-height: 25px; + height: 25px; + } +} +@media (min-device-width: 768px) and (max-device-width: 1200px) and (orientation: landscape) { + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .header__site-title__logo { + height: 25px; + } + .hg-app .header--fixed .header__nav .header__site-title .header__site-title__title .search-form .search-form__field { + padding: 5px 10px; + font-size: 14px; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__nav-links .header__nav-links__icon { + font-size: 18px; + } + .hg-app .header--fixed .header__nav .header__right-nav .header__nav-list .header__nav-list__item .header__nav-links .header__nav-links__text { + font-size: 16px; + } + .hg-app .content-wrapper { + padding-top: 35px; + } +} .header__site-title__item { font-size: 18px; line-height: 28px; @@ -322,32 +441,6 @@ html.touch .collapsible__body--disable { top: -6px; } -@media only screen and (max-device-width: 1200px) and (orientation: landscape) { - .hg-app .header__site-title__logo { - height: 25px; - } - .hg-app .header__site-title__logo--small { - height: 18px; - } - .hg-app .header__site-title__home { - position: relative; - top: -5px; - } - .hg-app .header__site-title__logotxt { - display: inline-block; - position: relative; - top: 0; - left: 8px; - } - .hg-app .header__nav-list__item__text { - display: inline-block; - margin-left: 8px; - } - .hg-app .header__button { - line-height: 25px; - height: 25px; - } -} .sub-navbar-container { border-bottom: 1px solid rgba(132, 146, 166, 0.3); border-top: 1px solid rgba(132, 146, 166, 0.3); @@ -672,16 +765,20 @@ html.touch .collapsible__body--disable { margin-left: 5px; } -.clickable-card { +.clickable-card, +.card-wrapper { color: #1F2D3D; display: block; cursor: pointer; - max-width: 400px; + text-decoration: none; } .clickable-card:focus, .clickable-card:hover, -.clickable-card:active { +.clickable-card:active, +.card-wrapper:focus, +.card-wrapper:hover, +.card-wrapper:active { color: #1F2D3D; outline: none; text-decoration: none; @@ -726,7 +823,6 @@ html.touch .collapsible__body--disable { .card--cfp { display: flex; flex-wrap: wrap; - box-shadow: none; margin-bottom: 30px; background: transparent; } @@ -771,7 +867,6 @@ html.touch .collapsible__body--disable { .card--upcoming .card__body, .card--cfp .card__body { flex: 0 0 100%; - padding: 0; } .card--upcoming .card__calendar, .card--cfp .card__calendar { @@ -780,6 +875,10 @@ html.touch .collapsible__body--disable { } @media (min-width: 768px) { + .card--upcoming, + .card--cfp { + box-shadow: none; + } .card--upcoming .card__image-wrapper, .card--upcoming .card__image--default, .card--cfp .card__image-wrapper, @@ -839,6 +938,23 @@ html.touch .collapsible__body--disable { margin-top: 10px; } +.session-card .session-card__header { + padding: 0; + display: flex; + background: none; + border-bottom: 1px solid rgba(132, 146, 166, 0.3); +} +.session-card .session-card__header .session-card__header__image { + width: 30%; + height: 100%; +} +.session-card .session-card__header .session-card__header__title { + width: 70%; + padding-left: 15px; + justify-content: center; + align-self: center; +} + .calendar { width: 100%; } @@ -861,6 +977,7 @@ html.touch .collapsible__body--disable { font-size: 18px; line-height: 28px; font-variant: all-small-caps; + width: 100%; } .calendar .calendar__weekdays__date { position: relative; @@ -938,7 +1055,7 @@ html.touch .collapsible__body--disable { margin-right: 15px; } .profile-details .profile-details__title { - word-break: break-all; + word-break: keep-all; } @media (min-width: 992px) { @@ -957,8 +1074,8 @@ html.touch .collapsible__body--disable { } } @media (min-width: 1200px) { - .desktop-head { - margin-top: 40px; + .project-header { + margin-top: 20px; } } .section-bottom-border { @@ -1117,23 +1234,6 @@ html.touch .collapsible__body--disable { margin-bottom: 2em; } -.project-section .session-card { - padding: 0; - display: flex; - background: none; - border-bottom: 1px solid rgba(132, 146, 166, 0.3); -} -.project-section .session-card .session-card__image { - width: 30%; - height: 100%; -} -.project-section .session-card .session-card__title { - width: 70%; - padding-left: 15px; - justify-content: center; - align-self: center; -} - .proposal-wrapper { border-radius: 4px; border: 1px solid rgba(132, 146, 166, 0.3); @@ -1374,21 +1474,23 @@ html.touch .collapsible__body--disable { .comment { margin: 1em 0; } - -.comment--content { +.comment .comment--content { display: inline-block; } - -.comment--content { +.comment .comment--content { width: calc(90% - 30px); } - -.comment--content img { +.comment .comment--content img { max-width: 100%; } - -.comment--body { - margin: 10px 0 0 20px; +.comment .comment--body { + margin: 10px 0 0 30px; +} +.comment .commenter--gravatar { + border-radius: 50%; + width: 25px; + vertical-align: bottom; + margin-right: 5px; } .label { @@ -1844,6 +1946,93 @@ body > .proposal-box.ui-draggable { text-decoration: none; } +.tabs { + display: flex; + width: 100%; + justify-content: space-between; + overflow: scroll; + align-items: center; + position: -webkit-sticky; + position: sticky; + top: 0; + order: 1; + z-index: 1000; + background: #F9FAFC; + margin-bottom: 2em; +} +.tabs .tabs__item { + padding: 1em; + cursor: pointer; + position: relative; + width: 100%; + border-bottom: 2px solid rgba(132, 146, 166, 0.3); + border-top: 2px solid rgba(132, 146, 166, 0.3); + border-left: 1px solid rgba(132, 146, 166, 0.3); + border-right: 1px solid rgba(132, 146, 166, 0.3); + margin: 0; + text-align: center; + align-self: stretch; +} +.tabs .tabs__item .chip { + margin-left: 10px; + border-radius: 2px; + padding: 0 10px; +} +.tabs .tabs__item:last-child { + border-right: 2px solid rgba(132, 146, 166, 0.3); +} +.tabs .tabs__item:first-child { + border-left: 2px solid rgba(132, 146, 166, 0.3); +} +.tabs .tabs__item--active, +.tabs .tabs__item--active:first-child, +.tabs .tabs__item--active:last-child { + border: 2px solid #856A91; +} + +@media (min-width: 768px) { + .tabs { + top: 50px; + } +} +.tab-content { + padding-left: 0; +} +.tab-content .tab-content__results { + margin-bottom: 1em; +} +.tab-content .tab-content__results .snippets { + margin-top: 8px; + padding: 8px 8px 8px 28px; + border: 1px solid rgba(132, 146, 166, 0.3); + position: relative; +} +.tab-content .tab-content__results .search-icon { + width: 20px; + display: inline-block; + position: absolute; + left: 8px; +} +.tab-content .tab-content__results .search-icon:after { + content: '🔍'; + opacity: 0.4; + position: relative; + top: 1px; +} + +.search-box { + padding: 8px 8px 8px 60px; + position: relative; +} +.search-box .user-gravatar { + width: 40px; + display: inline-block; + position: absolute; + left: 8px; + top: 11px; + border-radius: 50%; +} + .footer { padding-bottom: 50px; margin-top: 0; diff --git a/funnel/static/sass/_card.scss b/funnel/static/sass/_card.scss index d9bcf3493..4d90c528a 100644 --- a/funnel/static/sass/_card.scss +++ b/funnel/static/sass/_card.scss @@ -1,13 +1,17 @@ -.clickable-card { +.clickable-card, +.card-wrapper { color: #1F2D3D; display: block; cursor: pointer; - max-width: 400px; + text-decoration: none; } .clickable-card:focus, .clickable-card:hover, -.clickable-card:active { +.clickable-card:active, +.card-wrapper:focus, +.card-wrapper:hover, +.card-wrapper:active { color: #1F2D3D; outline: none; text-decoration: none; @@ -53,7 +57,6 @@ .card--cfp { display: flex; flex-wrap: wrap; - box-shadow: none; margin-bottom: 30px; background: transparent; @@ -94,7 +97,6 @@ .card__body { flex: 0 0 100%; - padding: 0; } .card__calendar { @@ -106,6 +108,8 @@ @media (min-width: 768px) { .card--upcoming, .card--cfp { + box-shadow: none; + .card__image-wrapper, .card__image--default { flex: 0 0 300px; @@ -167,6 +171,27 @@ margin-top: 10px; } +.session-card { + .session-card__header { + padding: 0; + display: flex; + background: none; + border-bottom: 1px solid rgba(132,146,166,0.3); + + .session-card__header__image { + width: 30%; + height: 100%; + } + + .session-card__header__title { + width: 70%; + padding-left: 15px; + justify-content: center; + align-self: center; + } + } +} + .calendar { width: 100%; @@ -192,6 +217,7 @@ font-size: 18px; line-height: 28px; font-variant: all-small-caps; + width: 100%; } .calendar__weekdays__date { diff --git a/funnel/static/sass/_header.scss b/funnel/static/sass/_header.scss index f6acb29c4..e91a13558 100644 --- a/funnel/static/sass/_header.scss +++ b/funnel/static/sass/_header.scss @@ -1,49 +1,256 @@ .hg-app { .header--fixed { bottom: 0; - } - .header__site-title { - width: calc(30% - 15px); - } - .header__site-title__logo { - height: 28px; - } - .header__right-nav { - width: calc(70% - 15px); - } - .header__button { - color: #fff; - line-height: 30.6px; - margin: 0; + + .header__nav { + padding: 0 15px; + justify-content: space-between; + + .header__site-title { + padding: 0; + + .header__site-title__logo { + height: 30px; + } + + .search-form { + position: fixed; + bottom: 50px; + width: 100%; + left: 0; + box-shadow: 0 1px 3px rgba(158,158,158,0.12), 0 1px 2px rgba(158,158,158,0.24); + display: none; + + .search-form__field { + width: 100%; + background-color: #eff2f6; + border: none; + padding: 15px 30px; + color: #1F2D3D; + font-size: 16px; + } + } + .search-form--show { + display: inline-block; + } + } + + .header__right-nav { + width: 100%; + padding: 0; + + .header__nav-list { + justify-content: space-between; + .header__nav-list__item { + + .header__nav-links { + .header__nav-links__icon { + font-size: 18px; + } + } + + .header__button { + color: #fff; + line-height: 30.6px; + margin: 0; + + .header__nav-links__text { + margin-left: 0 !important; + } + } + } + } + } + } } .content-wrapper { padding-top: 0; } } -@media (min-width: 1200px) { +@media (min-width: 768px) { .hg-app { .header--fixed { bottom: unset; top: 0; + + .header__nav { + .header__site-title { + width: 100%; + margin-right: 0; + + .header__site-title__title { + display: flex; + align-items: center; + + .header__site-title__logo { + height: 35px; + margin-right: 15px; + position: relative; + top: -2px; + } + + .search-form { + position: relative; + bottom: 0; + display: flex; + width: 400px; + margin: auto; + box-shadow: none; + + .search-form__field { + border-radius: 2px; + padding: 8px 10px; + } + } + } + } + + .header__right-nav { + width: auto; + .header__nav-list { + justify-content: flex-end; + .header__nav-list__item:first-child { + display: none; + } + .header__nav-list__item { + .header__nav-links .header__nav-links__icon { + display: inline; + font-size: 20px; + top: -2px; + } + .header__nav-links .header__nav-links__text { + display: inline; + font-size: 18px; + margin-left: 8px; + } + } + } + } + } } - .header__site-title__logo { - height: 35px; - } + .content-wrapper { padding-top: 52px; } + .no-sticky-header { + .header--fixed { + position: relative; + } + .content-wrapper { + padding-top: 0; + } + } } - .no-sticky-header { + +} + +@media (min-width: 1200px) { + .hg-app { .header--fixed { - position: relative; + .header__nav { + .header__site-title { + .header__site-title__title { + .search-form { + width: 400px; + } + } + } + } + } + } +} + +@media (min-width: 768px) and (max-width: 991px) { + .hg-app { + .header .mui-container { + max-width: 100%; + } + } +} + +@media only screen +and (max-device-width: 767px) +and (orientation: landscape) { + .hg-app { + + .header--fixed { + .header__nav { + + .header__site-title { + .header__site-title__title { + .header__site-title__logo { + height: 25px; + } + + .search-form { + bottom: 35px; + } + } + } + + .header__right-nav { + .header__nav-list { + .header__nav-list__item { + .header__nav-list__item__text { + display: inline-block; + margin-left: 8px; + } + .header__button { + line-height: 25px; + height: 25px; + } + } + } + } + } + } + } +} + +@media (min-device-width: 768px) +and (max-device-width: 1200px) +and (orientation: landscape) { + .hg-app { + + .header--fixed { + .header__nav { + + .header__site-title { + .header__site-title__title { + .header__site-title__logo { + height: 25px; + } + + .search-form { + .search-form__field { + padding: 5px 10px; + font-size: 14px; + } + } + } + } + .header__right-nav { + .header__nav-list { + .header__nav-list__item { + .header__nav-links .header__nav-links__icon { + font-size: 18px; + } + .header__nav-links .header__nav-links__text { + font-size: 16px; + } + } + } + } + } } .content-wrapper { - padding-top: 0; + padding-top: 35px; } } } + .header__site-title__item { font-size: 18px; line-height: 28px; @@ -100,37 +307,6 @@ top: -6px; } -@media only screen -and (max-device-width: 1200px) -and (orientation: landscape) { - .hg-app { - .header__site-title__logo { - height: 25px; - } - .header__site-title__logo--small { - height: 18px; - } - .header__site-title__home { - position: relative; - top: -5px; - } - .header__site-title__logotxt { - display: inline-block; - position: relative; - top: 0; - left: 8px; - } - .header__nav-list__item__text { - display: inline-block; - margin-left: 8px; - } - .header__button { - line-height: 25px; - height: 25px; - } - } -} - .sub-navbar-container { border-bottom: 1px solid rgba(132,146,166,0.3); border-top: 1px solid rgba(132,146,166,0.3); diff --git a/funnel/static/sass/_project.scss b/funnel/static/sass/_project.scss index e843248ac..0e482dc88 100644 --- a/funnel/static/sass/_project.scss +++ b/funnel/static/sass/_project.scss @@ -8,7 +8,7 @@ margin-right: 15px; } .profile-details__title { - word-break: break-all; + word-break: keep-all; } } @@ -29,8 +29,8 @@ } @media (min-width: 1200px) { - .desktop-head { - margin-top: 40px; + .project-header { + margin-top: 20px; } } @@ -196,22 +196,3 @@ outline: none; margin-bottom: 2em; } - -.project-section .session-card { - padding: 0; - display: flex; - background: none; - border-bottom: 1px solid rgba(132,146,166,0.3); - - .session-card__image { - width: 30%; - height: 100%; - } - - .session-card__title { - width: 70%; - padding-left: 15px; - justify-content: center; - align-self: center; - } -} diff --git a/funnel/static/sass/_proposal.scss b/funnel/static/sass/_proposal.scss index 1a7e93e0d..6937f0e93 100644 --- a/funnel/static/sass/_proposal.scss +++ b/funnel/static/sass/_proposal.scss @@ -248,22 +248,29 @@ .comment { margin: 1em 0; -} + + .comment--content { + display: inline-block; + } -.comment--content { - display: inline-block; -} + .comment--content { + width: calc(90% - 30px); + } -.comment--content { - width: calc(90% - 30px); -} + .comment--content img{ + max-width: 100%; + } -.comment--content img{ - max-width: 100%; -} + .comment--body { + margin: 10px 0 0 30px; + } -.comment--body { - margin: 10px 0 0 20px; + .commenter--gravatar { + border-radius: 50%; + width: 25px; + vertical-align: bottom; + margin-right: 5px; + } } .label { diff --git a/funnel/static/sass/_search.scss b/funnel/static/sass/_search.scss new file mode 100644 index 000000000..5988d1364 --- /dev/null +++ b/funnel/static/sass/_search.scss @@ -0,0 +1,91 @@ +.tabs { + display: flex; + width: 100%; + justify-content: space-between; + overflow: scroll; + align-items: center; + position: -webkit-sticky; + position: sticky; + top: 0; + order: 1; + z-index: 1000; + background: #F9FAFC; + margin-bottom: 2em; + + .tabs__item { + padding: 1em; + cursor: pointer; + position: relative; + width: 100%; + border-bottom: 2px solid rgba(132,146,166,0.3); + border-top: 2px solid rgba(132,146,166,0.3); + border-left: 1px solid rgba(132,146,166,0.3); + border-right: 1px solid rgba(132,146,166,0.3); + margin: 0; + text-align: center; + align-self: stretch; + + .chip { + margin-left: 10px; + border-radius: 2px; + padding: 0 10px; + } + } + .tabs__item:last-child { + border-right: 2px solid rgba(132,146,166,0.3); + } + .tabs__item:first-child { + border-left: 2px solid rgba(132,146,166,0.3); + } + .tabs__item--active, + .tabs__item--active:first-child, + .tabs__item--active:last-child { + border: 2px solid #856A91; + } +} + +@media(min-width: 768px) { + .tabs { + top: 50px; + } +} + +.tab-content { + padding-left: 0; + .tab-content__results { + margin-bottom: 1em; + + .snippets { + margin-top: 8px; + padding: 8px 8px 8px 28px; + border: 1px solid rgba(132,146,166,0.3); + position: relative; + } + .search-icon { + width: 20px; + display: inline-block; + position: absolute; + left: 8px; + } + .search-icon:after { + content: '🔍'; + opacity: 0.4; + position: relative; + top: 1px; + } + } +} + +.search-box { + padding: 8px 8px 8px 60px; + position: relative; + + .user-gravatar { + width: 40px; + display: inline-block; + position: absolute; + left: 8px; + top: 11px; + border-radius: 50%; + } +} diff --git a/funnel/static/sass/app.scss b/funnel/static/sass/app.scss index 300abff5e..97d0e24bc 100644 --- a/funnel/static/sass/app.scss +++ b/funnel/static/sass/app.scss @@ -12,4 +12,5 @@ @import "proposals"; @import "schedule"; @import "label"; +@import "search"; @import "footer"; diff --git a/funnel/templates/comments.html.jinja2 b/funnel/templates/comments.html.jinja2 index dc867a29b..3d627ef10 100644 --- a/funnel/templates/comments.html.jinja2 +++ b/funnel/templates/comments.html.jinja2 @@ -1,7 +1,7 @@ {% macro commenttree(comments, document, project, csrf_form) %} {%- for comment in comments %}
  • -
    +
    @@ -32,12 +32,12 @@ {%- endif %}
    {% if comment.children %} diff --git a/funnel/templates/index.html.jinja2 b/funnel/templates/index.html.jinja2 index a5a10966d..67b6dc582 100644 --- a/funnel/templates/index.html.jinja2 +++ b/funnel/templates/index.html.jinja2 @@ -76,7 +76,7 @@

    Spotlight

    -
    +
    {% if featured_project.bg_image.url %} {{ featured_project.title }} diff --git a/funnel/templates/layout.html.jinja2 b/funnel/templates/layout.html.jinja2 index dab6887a6..056699edc 100644 --- a/funnel/templates/layout.html.jinja2 +++ b/funnel/templates/layout.html.jinja2 @@ -55,6 +55,11 @@ {%- endif %} {%- else -%} +
    + + + +
    {%- endif %} {% endmacro %} @@ -63,9 +68,14 @@ {%- else %} {% with site_links=[] %} {% if not current_auth.is_anonymous -%} - {% set site_links = [{'title': 'Account', 'icon': 'account_circle', 'url': url_for("account"), 'name' :'account' }] %} + {% set site_links = [ + {'title': 'Search', 'icon': 'search', 'url': '', 'name' :'search', 'class': 'js-search-show'}, + {'title': 'Account', 'icon': 'account_circle', 'url': url_for("account"), 'name' :'account'} + ]%} {%- else %} - {% set site_links = [{'title': "Login", 'url': url_for("login"), 'class': 'mui-btn mui-btn--primary mui-btn--small mui-btn--raised header__button'}] %} + {% set site_links = [ + {'title': 'Search', 'icon': 'search', 'url': '', 'name' :'search', 'class': 'js-search-show'}, + {'title': "Login", 'url': url_for("login"), 'class': 'mui-btn mui-btn--primary mui-btn--small mui-btn--raised header__button'},] %} {%- endif %} {{ hgtopnav(site_title=site_title(), site_links=site_links, auth=false, network=false) }} {% endwith %} diff --git a/funnel/templates/macros.html.jinja2 b/funnel/templates/macros.html.jinja2 index 83c07af36..4c3f78631 100644 --- a/funnel/templates/macros.html.jinja2 +++ b/funnel/templates/macros.html.jinja2 @@ -15,11 +15,11 @@
    -
    +
    {%- if project.profile.logo_url.url %} {% endif %}
    diff --git a/funnel/templates/project.html.jinja2 b/funnel/templates/project.html.jinja2 index a74ad6533..961eb509a 100644 --- a/funnel/templates/project.html.jinja2 +++ b/funnel/templates/project.html.jinja2 @@ -140,12 +140,12 @@
  • {% if session.proposal %}
    {%- endif %} -
    +
    {% if session.banner_image_url.url %} -
    - {{ session.title }} +
    + {{ session.title }} {% if session.speaker %} -

    {{ session.speaker }}

    +

    {{ session.speaker }}

    {% endif %}
    {% else %} diff --git a/funnel/templates/proposal_comment_email.md b/funnel/templates/proposal_comment_email.md index 0192e7fc1..5e0473763 100644 --- a/funnel/templates/proposal_comment_email.md +++ b/funnel/templates/proposal_comment_email.md @@ -1,6 +1,6 @@ -**{{ g.user.pickername }}** left a comment on your proposal - **{{ proposal.title -}}**. +**{{ g.user.pickername }}** left a comment on your proposal: **{{ proposal.title +}}** {{ comment.message }} -View it on [funnel]({{link}}) +[View it on the website]({{link}}) diff --git a/funnel/templates/proposal_comment_reply_email.md b/funnel/templates/proposal_comment_reply_email.md index 0e3674e83..93cc072ed 100644 --- a/funnel/templates/proposal_comment_reply_email.md +++ b/funnel/templates/proposal_comment_reply_email.md @@ -1,6 +1,6 @@ -**{{ g.user.pickername }}** replied to a comment on your proposal - **{{ proposal.title -}}**. +**{{ g.user.pickername }}** replied to a comment on your proposal: **{{ proposal.title +}}** {{ comment.message }} -View it on [funnel]({{link}}) +[View it on the website]({{link}}) diff --git a/funnel/templates/proposals.html.jinja2 b/funnel/templates/proposals.html.jinja2 index 7c847eeed..fb6412619 100644 --- a/funnel/templates/proposals.html.jinja2 +++ b/funnel/templates/proposals.html.jinja2 @@ -51,7 +51,7 @@
  • {% endif %} {% if proposal.slides.url %} -
  • >
  • +
  • {% endif %} diff --git a/funnel/templates/search.html.jinja2 b/funnel/templates/search.html.jinja2 new file mode 100644 index 000000000..f1650128e --- /dev/null +++ b/funnel/templates/search.html.jinja2 @@ -0,0 +1,159 @@ +{% extends "layout.html.jinja2" %} +{% from "baseframe/forms.html.jinja2" import ajaxform %} +{% block title %}Search{% endblock %} +{% block description %}Search{% endblock %} + +{% block contentwrapper %} +
    +
    +
    + {% raw %} + + {% endraw %} +
    +
    +
    +{% endblock %} + +{% block footerscripts %} + {% assets "js_jquerysuccinct" -%} + + {%- endassets -%} + + +{% endblock %} diff --git a/funnel/views/__init__.py b/funnel/views/__init__.py index d1dfe90a2..383159cb0 100644 --- a/funnel/views/__init__.py +++ b/funnel/views/__init__.py @@ -2,4 +2,4 @@ # flake8: noqa from . import (index, login, profile, project, proposal, commentvote, - venue, schedule, session, event, participant, label, contact, account) + venue, schedule, session, event, participant, label, contact, account, search) diff --git a/funnel/views/search.py b/funnel/views/search.py new file mode 100644 index 000000000..4b17304f8 --- /dev/null +++ b/funnel/views/search.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- + +from collections import OrderedDict, namedtuple +from flask import Markup, request, redirect, url_for +import sqlalchemy.sql.expression as expression +from coaster.utils import for_tsquery +from coaster.views import requestargs, render_with, route, ClassView +from baseframe import __ +from ..models import db, User, Profile, Project, Proposal, Session, Comment +from .. import app, funnelapp + + +# --- Definitions ------------------------------------------------------------- + +# PostgreSQL ts_headline markers, picked for low probability of conflict with db content +pg_startsel = '' +pg_stopsel = '' +pg_delimiter = u' … ' + +# TODO: extend SearchModel to include profile_query_factory and project_query_factory +# for scoped search +SearchModel = namedtuple('SearchModel', ['label', 'model', 'has_title', 'query_factory']) + +# The order here is preserved into the tabs shown in UI +search_types = OrderedDict([ + ('project', SearchModel( + __("Projects"), Project, True, + lambda: Project.all_unsorted())), + ('profile', SearchModel( + __("Profiles"), Profile, True, + lambda: Profile.query)), + ('session', SearchModel( + __("Sessions"), Session, True, + lambda: Session.query.join(Proposal).join(User, Proposal.speaker))), # FIXME: undefer userinfo + ('proposal', SearchModel( + __("Proposals"), Proposal, True, + lambda: Proposal.query.join(User, Proposal.speaker).options(db.undefer('user.userinfo')))), + ('comment', SearchModel( + __("Comments"), Comment, False, + lambda: Comment.query.join(User).options(db.undefer('user.userinfo')))), + ]) + + +# --- Utilities --------------------------------------------------------------- + +def escape_quotes(text): + """PostgreSQL strips tags for us, but to be completely safe we need to escape quotes""" + return Markup(text.replace('"', '"').replace("'", ''')) + + +# --- Search functions -------------------------------------------------------- + +# @cache.memoize(timeout=300) +def search_counts(squery): + """Return counts of search results""" + return [{ + 'type': k, + 'label': v.label, + 'count': v.query_factory().options(db.load_only(v.model.id)).filter( + v.model.search_vector.match(squery)).count()} + for k, v in search_types.items()] + + +# @cache.memoize(timeout=300) +def search_results(squery, stype, page=1, per_page=20): + """Return search results""" + # Pick up model data for the given type string + st = search_types[stype] + regconfig = st.model.search_vector.type.options.get('regconfig', 'english') + + # Construct a basic query, sorted by matching column priority followed by date. + # TODO: Pick the right query factory depending on requested scoping. + # TODO: Pick a better date column than "created_at". + query = st.query_factory().filter( + st.model.search_vector.match(squery)).order_by( + db.desc(db.func.ts_rank_cd(st.model.search_vector, squery)), st.model.created_at.desc()) + + # Show rich summary by including the item's title with search terms highlighted + # (only if the item has a title) + if st.has_title: + title_column = db.func.ts_headline( + regconfig, + st.model.title, + db.func.to_tsquery(squery), + 'HighlightAll=TRUE, StartSel="%s", StopSel="%s"' % (pg_startsel, pg_stopsel), + type_=db.UnicodeText + ) + else: + title_column = expression.null() + + if 'hltext' in st.model.search_vector.type.options: + hltext = st.model.search_vector.type.options['hltext']() + else: + hltext = db.func.concat_ws( + ' / ', *(getattr(st.model, c) for c in st.model.search_vector.type.columns)) + + # Also show a snippet of the item's text with search terms highlighted + # Because we are searching against raw Markdown instead of rendered HTML, + # the snippet will be somewhat bland. We can live with it for now. + snippet_column = db.func.ts_headline( + regconfig, + hltext, + db.func.to_tsquery(squery), + 'MaxFragments=2, FragmentDelimiter="%s", ' + 'MinWords=5, MaxWords=20, ' + 'StartSel="%s", StopSel="%s"' % (pg_delimiter, pg_startsel, pg_stopsel), + type_=db.UnicodeText + ) + + # Add the two additional columns to the query and paginate results + query = query.add_columns(title_column, snippet_column) + pagination = query.paginate(page=page, per_page=per_page, max_per_page=100) + + # Return a page of results + return { + 'items': [{ + 'title': escape_quotes(title) if title is not None else None, + 'url': item.absolute_url, + 'snippet': escape_quotes(snippet), + 'obj': dict(item.current_access()), + } for item, title, snippet in pagination.items], + 'has_next': pagination.has_next, + 'has_prev': pagination.has_prev, + 'page': pagination.page, + 'per_page': pagination.per_page, + 'pages': pagination.pages, + 'next_num': pagination.next_num, + 'prev_num': pagination.prev_num, + 'count': pagination.total, + } + + +# --- Views ------------------------------------------------------------------- +class SearchView(ClassView): + @route('/search') + @render_with('search.html.jinja2', json=True) + @requestargs('q', ('page', int), ('per_page', int)) + def search(self, q=None, page=1, per_page=20): + squery = for_tsquery(q or '') + stype = request.args.get('type') # Can't use requestargs as it doesn't support name changes + if not squery: + return redirect(url_for('index')) + if stype is None or stype not in search_types: + return { + 'type': None, + 'counts': search_counts(squery) + } + else: + return { + 'type': stype, + 'counts': search_counts(squery), + 'results': search_results(squery, stype, page=page, per_page=per_page) + } + + +SearchView.init_app(app) +SearchView.init_app(funnelapp) diff --git a/migrations/versions/1829e53eba75_add_search_vectors.py b/migrations/versions/1829e53eba75_add_search_vectors.py new file mode 100644 index 000000000..fc2687a7f --- /dev/null +++ b/migrations/versions/1829e53eba75_add_search_vectors.py @@ -0,0 +1,166 @@ +"""Add search vectors + +Revision ID: 1829e53eba75 +Revises: 752dee4ae101 +Create Date: 2019-06-09 23:41:24.007858 + +""" + +# revision identifiers, used by Alembic. +revision = '1829e53eba75' +down_revision = '752dee4ae101' + +from textwrap import dedent +from alembic import op +import sqlalchemy as sa # NOQA +from sqlalchemy_utils import TSVectorType + + +def upgrade(): + op.add_column('comment', sa.Column('search_vector', TSVectorType(), nullable=True)) + op.create_index('ix_comment_search_vector', 'comment', ['search_vector'], + unique=False, postgresql_using='gin') + + op.add_column('label', sa.Column('search_vector', TSVectorType(), nullable=True)) + op.create_index('ix_label_search_vector', 'label', ['search_vector'], + unique=False, postgresql_using='gin') + + op.add_column('profile', sa.Column('search_vector', TSVectorType(), nullable=True)) + op.create_index('ix_profile_search_vector', 'profile', ['search_vector'], + unique=False, postgresql_using='gin') + + op.add_column('project', sa.Column('search_vector', TSVectorType(), nullable=True)) + op.create_index('ix_project_search_vector', 'project', ['search_vector'], + unique=False, postgresql_using='gin') + + op.add_column('proposal', sa.Column('search_vector', TSVectorType(), nullable=True)) + op.create_index('ix_proposal_search_vector', 'proposal', ['search_vector'], + unique=False, postgresql_using='gin') + + op.add_column('session', sa.Column('search_vector', TSVectorType(), nullable=True)) + op.create_index('ix_session_search_vector', 'session', ['search_vector'], + unique=False, postgresql_using='gin') + + # Update search vectors for existing data + op.execute(sa.DDL(dedent( + ''' + UPDATE comment SET search_vector = setweight(to_tsvector('english', COALESCE(message_text, '')), 'A'); + + UPDATE label SET search_vector = setweight(to_tsvector('english', COALESCE(name, '')), 'A') || setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(description, '')), 'B'); + + UPDATE profile SET search_vector = setweight(to_tsvector('english', COALESCE(name, '')), 'A') || setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(description_text, '')), 'B'); + + UPDATE project SET search_vector = setweight(to_tsvector('english', COALESCE(name, '')), 'A') || setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(description_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(instructions_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(location, '')), 'C'); + + UPDATE proposal SET search_vector = setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(abstract_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(outline_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(requirements_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(slides, '')), 'B') || setweight(to_tsvector('english', COALESCE(preview_video, '')), 'C') || setweight(to_tsvector('english', COALESCE(links, '')), 'B') || setweight(to_tsvector('english', COALESCE(bio_text, '')), 'B'); + + UPDATE session SET search_vector = setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(description_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(speaker_bio_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(speaker, '')), 'A'); + '''))) + + # Create trigger functions and add triggers + op.execute(sa.DDL(dedent( + ''' + CREATE FUNCTION comment_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.message_text, '')), 'A'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER comment_search_vector_trigger BEFORE INSERT OR UPDATE ON comment + FOR EACH ROW EXECUTE PROCEDURE comment_search_vector_update(); + + CREATE FUNCTION label_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER label_search_vector_trigger BEFORE INSERT OR UPDATE ON label + FOR EACH ROW EXECUTE PROCEDURE label_search_vector_update(); + + CREATE FUNCTION profile_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.description_text, '')), 'B'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER profile_search_vector_trigger BEFORE INSERT OR UPDATE ON profile + FOR EACH ROW EXECUTE PROCEDURE profile_search_vector_update(); + + CREATE FUNCTION project_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.description_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.instructions_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.location, '')), 'C'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER project_search_vector_trigger BEFORE INSERT OR UPDATE ON project + FOR EACH ROW EXECUTE PROCEDURE project_search_vector_update(); + + CREATE FUNCTION proposal_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.abstract_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.outline_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.requirements_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.slides, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.preview_video, '')), 'C') || setweight(to_tsvector('english', COALESCE(NEW.links, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.bio_text, '')), 'B'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER proposal_search_vector_trigger BEFORE INSERT OR UPDATE ON proposal + FOR EACH ROW EXECUTE PROCEDURE proposal_search_vector_update(); + + CREATE FUNCTION session_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.description_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.speaker_bio_text, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.speaker, '')), 'A'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + + CREATE TRIGGER session_search_vector_trigger BEFORE INSERT OR UPDATE ON session + FOR EACH ROW EXECUTE PROCEDURE session_search_vector_update(); + '''))) + + op.alter_column('comment', 'search_vector', nullable=False) + op.alter_column('label', 'search_vector', nullable=False) + op.alter_column('profile', 'search_vector', nullable=False) + op.alter_column('project', 'search_vector', nullable=False) + op.alter_column('proposal', 'search_vector', nullable=False) + op.alter_column('session', 'search_vector', nullable=False) + + +def downgrade(): + # Drop triggers and functions + op.execute(sa.DDL(dedent( + ''' + DROP TRIGGER comment_search_vector_trigger ON comment; + DROP FUNCTION comment_search_vector_update(); + + DROP TRIGGER label_search_vector_trigger ON label; + DROP FUNCTION label_search_vector_update(); + + DROP TRIGGER profile_search_vector_trigger ON profile; + DROP FUNCTION profile_search_vector_update(); + + DROP TRIGGER project_search_vector_trigger ON project; + DROP FUNCTION project_search_vector_update(); + + DROP TRIGGER proposal_search_vector_trigger ON proposal; + DROP FUNCTION proposal_search_vector_update(); + + DROP TRIGGER session_search_vector_trigger ON session; + DROP FUNCTION session_search_vector_update(); + '''))) + + op.drop_index('ix_session_search_vector', table_name='session') + op.drop_column('session', 'search_vector') + op.drop_index('ix_proposal_search_vector', table_name='proposal') + op.drop_column('proposal', 'search_vector') + op.drop_index('ix_project_search_vector', table_name='project') + op.drop_column('project', 'search_vector') + op.drop_index('ix_profile_search_vector', table_name='profile') + op.drop_column('profile', 'search_vector') + op.drop_index('ix_label_search_vector', table_name='label') + op.drop_column('label', 'search_vector') + op.drop_index('ix_comment_search_vector', table_name='comment') + op.drop_column('comment', 'search_vector')