Commit 8d6f24fc53f695213e2b6b8478d1508fbca16f02

Authored by Sergio Oliveira
2 parents b1879e4e 990d7321

Merge branch 'master' of github.com:TracyWebTech/colab

Showing 43 changed files with 1192 additions and 128 deletions   Show diff stats
requirements.txt
@@ -8,6 +8,7 @@ chardet==1.0.1 @@ -8,6 +8,7 @@ chardet==1.0.1
8 python-dateutil==1.5 8 python-dateutil==1.5
9 django-cliauth==0.9 9 django-cliauth==0.9
10 django-mobile==0.3.0 10 django-mobile==0.3.0
  11 +django-haystack==2.1
11 etiquetando==0.1 12 etiquetando==0.1
12 html2text 13 html2text
13 django-taggit 14 django-taggit
src/accounts/search_indexes.py 0 → 100644
@@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
  1 +# -*- coding: utf-8 -*-
  2 +
  3 +from haystack import indexes
  4 +
  5 +from .models import User
  6 +
  7 +
  8 +class UserIndex(indexes.SearchIndex, indexes.Indexable):
  9 + # common fields
  10 + text = indexes.CharField(document=True, use_template=True)
  11 + url = indexes.CharField(model_attr='get_absolute_url')
  12 + title = indexes.CharField(model_attr='get_full_name')
  13 + description = indexes.CharField(null=True)
  14 + type = indexes.CharField()
  15 + icon_name = indexes.CharField()
  16 +
  17 + # extra fields
  18 + username = indexes.CharField(model_attr='username')
  19 + name = indexes.CharField(model_attr='get_full_name')
  20 + email = indexes.CharField(model_attr='email')
  21 + institution = indexes.CharField(model_attr='institution', null=True)
  22 + role = indexes.CharField(model_attr='role', null=True)
  23 + google_talk = indexes.CharField(model_attr='google_talk', null=True)
  24 + webpage = indexes.CharField(model_attr='webpage', null=True)
  25 +
  26 + def get_model(self):
  27 + return User
  28 +
  29 + def get_updated_field(self):
  30 + return 'date_joined'
  31 +
  32 + def prepare_description(self, obj):
  33 + return u'{}\n{}\n{}\n{}\n{}\n{}'.format(
  34 + obj.institution, obj.role, obj.username, obj.get_full_name(),
  35 + obj.google_talk, obj.webpage
  36 + )
  37 +
  38 + def prepare_icon_name(self, obj):
  39 + return u'user'
  40 +
  41 + def prepare_type(self, obj):
  42 + return u'user'
  43 +
  44 + def index_queryset(self, using=None):
  45 + return self.get_model().objects.filter(is_active=True)
src/accounts/templates/search/indexes/accounts/user_text.txt 0 → 100644
@@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
  1 +{{ object.username }}
  2 +{{ object.get_full_name }}
  3 +{{ object.get_full_name|slugify }}
  4 +{{ object.institution }}
  5 +{{ object.institution|slugify }}
  6 +{{ object.role }}
  7 +{{ object.role|slugify }}
src/colab/custom_settings.py
@@ -15,6 +15,56 @@ LANGUAGES = ( @@ -15,6 +15,56 @@ LANGUAGES = (
15 15
16 LANGUAGE_CODE = 'pt-br' 16 LANGUAGE_CODE = 'pt-br'
17 17
  18 +# ORDERING_DATA receives the options to order for as it's keys and a dict as
  19 +# value, if you want to order for the last name, you can use something like:
  20 +# 'last_name': {'name': 'Last Name', 'fields': 'last_name'} inside the dict,
  21 +# you pass two major keys (name, fields)
  22 +# The major key name is the name to appear on the template
  23 +# the major key fields it show receive the name of the fields to order for in
  24 +# the indexes
  25 +
  26 +ORDERING_DATA = {
  27 + 'latest': {
  28 + 'name': gettext(u'Recent activity'),
  29 + 'fields': ('-modified', '-created'),
  30 + },
  31 + 'hottest': {
  32 + 'name': gettext(u'Relevance'),
  33 + 'fields': None,
  34 + },
  35 +}
  36 +
  37 +# the following variable define how many characters should be shown before
  38 +# a highlighted word, to make sure that the highlighted word will appear
  39 +HIGHLIGHT_NUM_CHARS_BEFORE_MATCH = 30
  40 +HAYSTACK_CUSTOM_HIGHLIGHTER = 'colab.utils.highlighting.ColabHighlighter'
  41 +
  42 +HAYSTACK_CONNECTIONS = {
  43 + 'default': {
  44 + 'ENGINE': 'haystack.backends.solr_backend.SolrEngine',
  45 + 'URL': os.environ.get('COLAB_SOLR_URL'),
  46 + }
  47 +}
  48 +
  49 +DATABASES = {
  50 + 'default': {
  51 + 'ENGINE': 'django.db.backends.postgresql_psycopg2',
  52 + 'NAME': 'colab',
  53 + 'USER': 'colab',
  54 + 'PASSWORD': os.environ.get('COLAB_DEFAULT_DB_PWD'),
  55 + 'HOST': os.environ.get('COLAB_DEFAULT_DB_HOST'),
  56 + },
  57 + 'trac': {
  58 + 'ENGINE': 'django.db.backends.postgresql_psycopg2',
  59 + 'NAME': 'trac',
  60 + 'USER': 'trac',
  61 + 'PASSWORD': os.environ.get('COLAB_TRAC_DB_PWD'),
  62 + 'HOST': os.environ.get('COLAB_TRAC_DB_HOST'),
  63 + }
  64 +}
  65 +
  66 +DATABASE_ROUTERS = ['colab.routers.TracRouter',]
  67 +
18 INSTALLED_APPS = INSTALLED_APPS + ( 68 INSTALLED_APPS = INSTALLED_APPS + (
19 69
20 # Not standard apps 70 # Not standard apps
@@ -24,6 +74,7 @@ INSTALLED_APPS = INSTALLED_APPS + ( @@ -24,6 +74,7 @@ INSTALLED_APPS = INSTALLED_APPS + (
24 'django_mobile', 74 'django_mobile',
25 'django_browserid', 75 'django_browserid',
26 'conversejs', 76 'conversejs',
  77 + 'haystack',
27 78
28 # Own apps 79 # Own apps
29 'super_archives', 80 'super_archives',
@@ -33,6 +84,7 @@ INSTALLED_APPS = INSTALLED_APPS + ( @@ -33,6 +84,7 @@ INSTALLED_APPS = INSTALLED_APPS + (
33 'planet', 84 'planet',
34 'accounts', 85 'accounts',
35 'proxy', 86 'proxy',
  87 + 'search',
36 88
37 # Feedzilla and deps 89 # Feedzilla and deps
38 'feedzilla', 90 'feedzilla',
src/colab/deprecated/views/other.py
@@ -6,12 +6,16 @@ other.py @@ -6,12 +6,16 @@ other.py
6 Created by Sergio Campos on 2012-01-10. 6 Created by Sergio Campos on 2012-01-10.
7 """ 7 """
8 8
  9 +import datetime
  10 +
9 from django.template import RequestContext 11 from django.template import RequestContext
10 from django.http import HttpResponseNotAllowed 12 from django.http import HttpResponseNotAllowed
11 from django.shortcuts import render_to_response 13 from django.shortcuts import render_to_response
  14 +from django.utils import timezone
12 from django.utils.translation import ugettext as _ 15 from django.utils.translation import ugettext as _
13 16
14 -from colab.deprecated import solrutils 17 +from haystack.query import SearchQuerySet
  18 +
15 from super_archives import queries 19 from super_archives import queries
16 20
17 21
@@ -21,11 +25,21 @@ def home(request): @@ -21,11 +25,21 @@ def home(request):
21 latest_threads = queries.get_latest_threads() 25 latest_threads = queries.get_latest_threads()
22 hottest_threads = queries.get_hottest_threads() 26 hottest_threads = queries.get_hottest_threads()
23 27
  28 + count_types = {}
  29 + six_months = timezone.now() - datetime.timedelta(days=180)
  30 + for type in ['wiki', 'thread', 'changeset', 'ticket']:
  31 + count_types[type] = SearchQuerySet().filter(
  32 + type=type,
  33 + modified__gte=six_months,
  34 + ).count()
  35 +
24 template_data = { 36 template_data = {
25 'hottest_threads': hottest_threads[:6], 37 'hottest_threads': hottest_threads[:6],
26 'latest_threads': latest_threads[:6], 38 'latest_threads': latest_threads[:6],
27 - 'type_count': solrutils.count_types(sample=1000),  
28 - 'latest_docs': solrutils.get_latest_collaborations(6), 39 + 'type_count': count_types,
  40 + 'latest_results': SearchQuerySet().all().order_by(
  41 + '-modified', '-created'
  42 + )[:6],
29 } 43 }
30 return render_to_response('home.html', template_data, 44 return render_to_response('home.html', template_data,
31 context_instance=RequestContext(request)) 45 context_instance=RequestContext(request))
@@ -34,7 +48,7 @@ def home(request): @@ -34,7 +48,7 @@ def home(request):
34 def search(request): 48 def search(request):
35 if request.method != 'GET': 49 if request.method != 'GET':
36 return HttpResponseNotAllowed(['GET']) 50 return HttpResponseNotAllowed(['GET'])
37 - 51 +
38 query = request.GET.get('q') 52 query = request.GET.get('q')
39 sort = request.GET.get('o') 53 sort = request.GET.get('o')
40 type_ = request.GET.get('type') 54 type_ = request.GET.get('type')
@@ -42,7 +56,7 @@ def search(request): @@ -42,7 +56,7 @@ def search(request):
42 page_number = int(request.GET.get('p', '1')) 56 page_number = int(request.GET.get('p', '1'))
43 except ValueError: 57 except ValueError:
44 page_number = 1 58 page_number = 1
45 - 59 +
46 try: 60 try:
47 results_per_page = int(request.GET.get('per_page', 16)) 61 results_per_page = int(request.GET.get('per_page', 16))
48 except ValueError: 62 except ValueError:
src/colab/routers.py 0 → 100644
@@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
  1 +class TracRouter(object):
  2 + def db_for_read(self, model, **hints):
  3 + if model._meta.app_label == 'proxy':
  4 + return 'trac'
  5 + return None
  6 +
  7 + def db_for_write(self, model, **hints):
  8 + if model._meta.app_label == 'proxy':
  9 + return 'trac'
  10 + return None
  11 +
  12 + def allow_relation(self, obj1, obj2, **hints):
  13 + if obj1._meta.app_label == 'proxy' or \
  14 + obj2._meta.app_label == 'proxy':
  15 + return True
  16 + return None
  17 +
  18 + def allow_migrate(self, db, model):
  19 + if db == 'trac':
  20 + return model._meta.app_label == 'proxy'
  21 + elif model._meta.app_label == 'proxy':
  22 + False
  23 + return None
src/colab/urls.py
1 -  
2 from django.conf.urls import patterns, include, url 1 from django.conf.urls import patterns, include, url
3 from django.conf import settings 2 from django.conf import settings
4 from django.views.generic import TemplateView 3 from django.views.generic import TemplateView
5 from django.contrib import admin 4 from django.contrib import admin
6 5
  6 +from accounts.models import User
  7 +from search.forms import ColabSearchForm
  8 +from super_archives.models import Message
  9 +
7 10
8 admin.autodiscover() 11 admin.autodiscover()
9 12
10 urlpatterns = patterns('', 13 urlpatterns = patterns('',
11 url(r'^$', 'colab.deprecated.views.other.home', name='home'), 14 url(r'^$', 'colab.deprecated.views.other.home', name='home'),
12 15
13 - url(r'^search/$', 'colab.deprecated.views.other.search', name='search'),  
14 - 16 + url(r'^search/', include('search.urls')),
15 url(r'open-data/$', TemplateView.as_view(template_name='open-data.html'), 17 url(r'open-data/$', TemplateView.as_view(template_name='open-data.html'),
16 name='opendata'), 18 name='opendata'),
17 19
src/colab/utils/__init__.py 0 → 100644
src/colab/utils/highlighting.py 0 → 100644
@@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
  1 +from haystack.utils import Highlighter
  2 +from django.conf import settings
  3 +
  4 +
  5 +class ColabHighlighter(Highlighter):
  6 + def find_window(self, highlight_locations):
  7 + """Getting the HIGHLIGHT_NUM_CHARS_BEFORE_MATCH setting
  8 + to find how many characters before the first word found should
  9 + be removed from the window
  10 + """
  11 +
  12 + if len(self.text_block) <= self.max_length:
  13 + return (0, self.max_length)
  14 +
  15 + num_chars_before = getattr(
  16 + settings,
  17 + 'HIGHLIGHT_NUM_CHARS_BEFORE_MATCH',
  18 + 0
  19 + )
  20 +
  21 + best_start, best_end = super(ColabHighlighter, self).find_window(
  22 + highlight_locations
  23 + )
  24 + if best_start <= num_chars_before:
  25 + best_end -= best_start
  26 + best_start = 0
  27 + else:
  28 + best_start -= num_chars_before
  29 + best_end -= num_chars_before
  30 +
  31 + return (best_start, best_end)
src/proxy/migrations/0001_create_views.py 0 → 100644
@@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
  1 +# -*- coding: utf-8 -*-
  2 +import datetime
  3 +from django.db import connections
  4 +from south.db import db
  5 +from south.v2 import DataMigration
  6 +from django.db import models
  7 +
  8 +
  9 +class Migration(DataMigration):
  10 +
  11 + def forwards(self, orm):
  12 + # Selecting trac database
  13 + connection = connections['trac']
  14 +
  15 + cursor = connection.cursor()
  16 + cursor.execute('''
  17 + CREATE OR REPLACE VIEW wiki_view AS SELECT
  18 + wiki.name AS name,
  19 + (SELECT wiki2.text FROM wiki AS wiki2 WHERE wiki2.name = wiki.name
  20 + AND wiki2.version = MAX(wiki.version)) AS wiki_text,
  21 + (SELECT wiki3.author FROM wiki AS wiki3 WHERE wiki3.name = wiki.name
  22 + AND wiki3.version = 1) AS author,
  23 + string_agg(DISTINCT wiki.author, ', ') AS collaborators,
  24 + TIMESTAMP WITH TIME ZONE 'epoch' + (MAX(wiki.time)/1000000) * INTERVAL '1s' AS created,
  25 + TIMESTAMP WITH TIME ZONE 'epoch' + (MIN(wiki.time)/1000000) * INTERVAL '1s' AS modified
  26 + FROM wiki
  27 + GROUP BY wiki.name;
  28 +
  29 + CREATE OR REPLACE VIEW ticket_view AS SELECT
  30 + ticket.id AS id,
  31 + ticket.summary as summary,
  32 + ticket.description as description,
  33 + ticket.milestone as milestone,
  34 + ticket.priority as priority,
  35 + ticket.component as component,
  36 + ticket.version as version,
  37 + ticket.severity as severity,
  38 + ticket.reporter as reporter,
  39 + ticket.reporter as author,
  40 + ticket.status as status,
  41 + ticket.keywords as keywords,
  42 + (SELECT
  43 + string_agg(DISTINCT ticket_change.author, ', ')
  44 + FROM ticket_change WHERE ticket_change.ticket = ticket.id
  45 + GROUP BY ticket_change.ticket) as collaborators,
  46 + TIMESTAMP WITH TIME ZONE 'epoch' + (time/1000000)* INTERVAL '1s' AS created,
  47 + TIMESTAMP WITH TIME ZONE 'epoch' + (changetime/1000000) * INTERVAL '1s' AS modified
  48 + FROM ticket;
  49 +
  50 + CREATE OR REPLACE VIEW revision_view AS SELECT
  51 + revision.rev,
  52 + revision.author,
  53 + revision.message,
  54 + repository.value AS repository_name,
  55 + TIMESTAMP WITH TIME ZONE 'epoch' + (revision.time/1000000) * INTERVAL '1s' AS created
  56 + FROM revision
  57 + INNER JOIN repository ON(
  58 + repository.id = revision.repos
  59 + AND repository.name = 'name'
  60 + AND repository.value != ''
  61 + );
  62 + ''')
  63 +
  64 + def backwards(self, orm):
  65 + # Selecting trac database
  66 + connection = connections['trac']
  67 +
  68 + cursor = connection.cursor()
  69 + cursor.execute('''
  70 + DROP VIEW IF EXISTS revision_view;
  71 + DROP VIEW IF EXISTS ticket_view;
  72 + DROP VIEW IF EXISTS wiki_view;
  73 + ''')
  74 +
  75 +
  76 + models = {
  77 + u'proxy.revision': {
  78 + 'Meta': {'object_name': 'Revision', 'db_table': "'revision_view'", 'managed': 'False'},
  79 + 'author': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  80 + 'created': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
  81 + 'message': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  82 + 'repository_name': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  83 + 'rev': ('django.db.models.fields.TextField', [], {'primary_key': 'True'})
  84 + },
  85 + u'proxy.ticket': {
  86 + 'Meta': {'object_name': 'Ticket', 'db_table': "'ticket_view'", 'managed': 'False'},
  87 + 'author': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  88 + 'collaborators': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  89 + 'component': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  90 + 'created': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
  91 + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  92 + 'id': ('django.db.models.fields.IntegerField', [], {'primary_key': 'True'}),
  93 + 'keywords': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  94 + 'milestone': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  95 + 'modified': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
  96 + 'priority': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  97 + 'reporter': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  98 + 'severity': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  99 + 'status': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  100 + 'summary': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  101 + 'version': ('django.db.models.fields.TextField', [], {'blank': 'True'})
  102 + },
  103 + u'proxy.wiki': {
  104 + 'Meta': {'object_name': 'Wiki', 'db_table': "'wiki_view'", 'managed': 'False'},
  105 + 'author': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  106 + 'collaborators': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
  107 + 'created': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
  108 + 'modified': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
  109 + 'name': ('django.db.models.fields.TextField', [], {'primary_key': 'True'}),
  110 + 'wiki_text': ('django.db.models.fields.TextField', [], {'blank': 'True'})
  111 + }
  112 + }
  113 +
  114 + complete_apps = ['proxy']
  115 + symmetrical = True
src/proxy/migrations/__init__.py 0 → 100644
src/proxy/models.py
  1 +# -*- coding: utf-8 -*-
  2 +
1 from django.db import models 3 from django.db import models
2 4
3 -# Create your models here. 5 +from accounts.models import User
  6 +
  7 +
  8 +# get_absolute_url em todos
  9 +# get_author_url em todos
  10 +
  11 +
  12 +class Revision(models.Model):
  13 + rev = models.TextField(blank=True, primary_key=True)
  14 + author = models.TextField(blank=True)
  15 + message = models.TextField(blank=True)
  16 + repository_name = models.TextField(blank=True)
  17 + created = models.DateTimeField(blank=True, null=True)
  18 +
  19 + class Meta:
  20 + managed = False
  21 + db_table = 'revision_view'
  22 +
  23 + def get_absolute_url(self):
  24 + return u'/changeset/{}/{}'.format(self.rev, self.repository_name)
  25 +
  26 + def get_author(self):
  27 + try:
  28 + return User.objects.get(username=self.author)
  29 + except User.DoesNotExist:
  30 + return None
  31 +
  32 +class Ticket(models.Model):
  33 + id = models.IntegerField(primary_key=True)
  34 + summary = models.TextField(blank=True)
  35 + description = models.TextField(blank=True)
  36 + milestone = models.TextField(blank=True)
  37 + priority = models.TextField(blank=True)
  38 + component = models.TextField(blank=True)
  39 + version = models.TextField(blank=True)
  40 + severity = models.TextField(blank=True)
  41 + reporter = models.TextField(blank=True)
  42 + author = models.TextField(blank=True)
  43 + status = models.TextField(blank=True)
  44 + keywords = models.TextField(blank=True)
  45 + collaborators = models.TextField(blank=True)
  46 + created = models.DateTimeField(blank=True, null=True)
  47 + modified = models.DateTimeField(blank=True, null=True)
  48 +
  49 + class Meta:
  50 + managed = False
  51 + db_table = 'ticket_view'
  52 +
  53 + def get_absolute_url(self):
  54 + return u'/ticket/{}'.format(self.id)
  55 +
  56 + def get_author(self):
  57 + try:
  58 + return User.objects.get(username=self.author)
  59 + except User.DoesNotExist:
  60 + return None
  61 +
  62 +
  63 +class Wiki(models.Model):
  64 + name = models.TextField(primary_key=True)
  65 + wiki_text = models.TextField(blank=True)
  66 + author = models.TextField(blank=True)
  67 + collaborators = models.TextField(blank=True)
  68 + created = models.DateTimeField(blank=True, null=True)
  69 + modified = models.DateTimeField(blank=True, null=True)
  70 +
  71 + class Meta:
  72 + managed = False
  73 + db_table = 'wiki_view'
  74 +
  75 + def get_absolute_url(self):
  76 + return u'/ticket/{}'.format(self.name)
  77 +
  78 + def get_author(self):
  79 + try:
  80 + return User.objects.get(username=self.author)
  81 + except User.DoesNotExist:
  82 + return None
src/proxy/search_indexes.py 0 → 100644
@@ -0,0 +1,153 @@ @@ -0,0 +1,153 @@
  1 +# -*- coding: utf-8 -*-
  2 +
  3 +from datetime import datetime
  4 +
  5 +from django.db.models import Q
  6 +from haystack import indexes
  7 +
  8 +from .models import Ticket, Wiki, Revision
  9 +
  10 +
  11 +class WikiIndex(indexes.SearchIndex, indexes.Indexable):
  12 + # common fields
  13 + text = indexes.CharField(document=True, use_template=True)
  14 + url = indexes.CharField(model_attr='get_absolute_url')
  15 + title = indexes.CharField(model_attr='name')
  16 + description = indexes.CharField(null=True)
  17 + author = indexes.CharField(null=True)
  18 + author_url = indexes.CharField(null=True)
  19 + created = indexes.DateTimeField(model_attr='created', null=True)
  20 + modified = indexes.DateTimeField(model_attr='modified', null=True)
  21 + type = indexes.CharField()
  22 + icon_name = indexes.CharField()
  23 +
  24 + # trac extra fields
  25 + collaborators = indexes.CharField(model_attr='collaborators', null=True)
  26 +
  27 + def get_model(self):
  28 + return Wiki
  29 +
  30 + def get_updated_field(self):
  31 + return 'modified'
  32 +
  33 + def prepare_author(self, obj):
  34 + author = obj.get_author()
  35 + if author:
  36 + return author.get_full_name()
  37 + return obj.author
  38 +
  39 + def prepare_author_url(self, obj):
  40 + author = obj.get_author()
  41 + if author:
  42 + return author.get_absolute_url()
  43 + return None
  44 +
  45 + def prepare_description(self, obj):
  46 + return u'{}\n{}'.format(obj.wiki_text, obj.collaborators)
  47 +
  48 + def prepare_icon_name(self, obj):
  49 + return u'file'
  50 +
  51 + def prepare_type(self, obj):
  52 + return u'wiki'
  53 +
  54 +
  55 +class TicketIndex(indexes.SearchIndex, indexes.Indexable):
  56 + # common fields
  57 + text = indexes.CharField(document=True, use_template=True)
  58 + url = indexes.CharField(model_attr='get_absolute_url')
  59 + title = indexes.CharField()
  60 + description = indexes.CharField(null=True)
  61 + author = indexes.CharField(null=True)
  62 + author_url = indexes.CharField(null=True)
  63 + created = indexes.DateTimeField(model_attr='created', null=True)
  64 + modified = indexes.DateTimeField(model_attr='modified', null=True)
  65 + type = indexes.CharField()
  66 + icon_name = indexes.CharField()
  67 + tag = indexes.CharField(model_attr='status', null=True)
  68 +
  69 + # trac extra fields
  70 + milestone = indexes.CharField(model_attr='milestone', null=True)
  71 + component = indexes.CharField(model_attr='component', null=True)
  72 + severity = indexes.CharField(model_attr='severity', null=True)
  73 + reporter = indexes.CharField(model_attr='reporter', null=True)
  74 + keywords = indexes.CharField(model_attr='keywords', null=True)
  75 + collaborators = indexes.CharField(model_attr='collaborators', null=True)
  76 +
  77 + def get_model(self):
  78 + return Ticket
  79 +
  80 + def get_updated_field(self):
  81 + return 'modified'
  82 +
  83 + def prepare_author(self, obj):
  84 + author = obj.get_author()
  85 + if author:
  86 + return author.get_full_name()
  87 + return obj.author
  88 +
  89 + def prepare_author_url(self, obj):
  90 + author = obj.get_author()
  91 + if author:
  92 + return author.get_absolute_url()
  93 + return None
  94 +
  95 + def prepare_description(self, obj):
  96 + return u'{}\n{}\n{}\n{}\n{}\n{}\n{}'.format(
  97 + obj.description, obj.milestone, obj.component, obj.severity,
  98 + obj.reporter, obj.keywords, obj.collaborators
  99 + )
  100 +
  101 + def prepare_icon_name(self, obj):
  102 + return u'tag'
  103 +
  104 + def prepare_title(self, obj):
  105 + return u'#{} - {}'.format(obj.pk, obj.summary)
  106 +
  107 + def prepare_type(self, obj):
  108 + return 'ticket'
  109 +
  110 +
  111 +class RevisionIndex(indexes.SearchIndex, indexes.Indexable):
  112 + # common fields
  113 + text = indexes.CharField(document=True, use_template=True)
  114 + url = indexes.CharField(model_attr='get_absolute_url')
  115 + title = indexes.CharField()
  116 + description = indexes.CharField(model_attr='message', null=True)
  117 + author = indexes.CharField(null=True)
  118 + author_url = indexes.CharField(null=True)
  119 + created = indexes.DateTimeField(model_attr='created', null=True)
  120 + modified = indexes.DateTimeField(model_attr='created', null=True)
  121 + type = indexes.CharField()
  122 + icon_name = indexes.CharField()
  123 +
  124 + # trac extra fields
  125 + repository_name = indexes.CharField(model_attr='repository_name')
  126 + revision = indexes.CharField(model_attr='rev')
  127 +
  128 + def get_model(self):
  129 + return Revision
  130 +
  131 + def get_updated_field(self):
  132 + return 'created'
  133 +
  134 + def prepare_author(self, obj):
  135 + author = obj.get_author()
  136 + if author:
  137 + return author.get_full_name()
  138 + return obj.author
  139 +
  140 + def prepare_author_url(self, obj):
  141 + author = obj.get_author()
  142 + if author:
  143 + return author.get_absolute_url()
  144 + return None
  145 +
  146 + def prepare_icon_name(self, obj):
  147 + return u'align-right'
  148 +
  149 + def prepare_title(self, obj):
  150 + return u'{} [{}]'.format(obj.repository_name, obj.rev)
  151 +
  152 + def prepare_type(self, obj):
  153 + return 'changeset'
src/proxy/templates/search/indexes/proxy/revision_text.txt 0 → 100644
@@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
  1 +{{ object.repository_name }}
  2 +{{ object.repository_name|slugify }}
  3 +{{ object.rev }}
  4 +{{ object.rev|slugify }}
  5 +{% firstof object.get_author.get_full_name object.author %}
  6 +{% firstof object.get_author.get_full_name|slugify object.author|slugify %}
  7 +{{ object.message }}
  8 +{{ object.message|slugify }}
src/proxy/templates/search/indexes/proxy/ticket_text.txt 0 → 100644
@@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
  1 +{{ object.summary }}
  2 +{{ object.summary|slugify }}
  3 +{{ object.description }}
  4 +{{ object.description|slugify }}
  5 +{{ object.milestone }}
  6 +{{ object.milestone|slugify }}
  7 +{{ object.component|slugify }}
  8 +{{ object.version }}
  9 +{{ object.severity }}
  10 +{{ object.severity|slugify }}
  11 +{{ object.reporter }}
  12 +{{ object.reporter|slugify }}
  13 +{% firstof object.get_author.get_fullname or object.author %}
  14 +{% firstof object.get_author.get_fullname|slugify or object.author|slugify %}
  15 +{{ object.status }}
  16 +{{ object.status|slugify }}
  17 +{{ object.keywords }}
  18 +{{ object.keywords|slugify }}
  19 +{{ object.collaborators }}
  20 +{{ object.collaborators|slugify }}
src/proxy/templates/search/indexes/proxy/wiki_text.txt 0 → 100644
@@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
  1 +{{ object.author }}
  2 +{{ object.author|slugify }}
  3 +{{ object.name }}
  4 +{{ object.name|slugify }}
  5 +{{ object.collaborators }}
  6 +{{ object.collaborators|slugify }}
  7 +{{ object.wiki_text }}
  8 +{{ object.wiki_text|slugify }}
src/search/__init__.py 0 → 100644
src/search/admin.py 0 → 100644
@@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
  1 +from django.contrib import admin
  2 +
  3 +# Register your models here.
src/search/forms.py 0 → 100644
@@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
  1 +# -*- coding: utf-8 -*-
  2 +
  3 +import unicodedata
  4 +
  5 +from django import forms
  6 +from django.conf import settings
  7 +from django.utils.translation import ugettext_lazy as _
  8 +from haystack.forms import SearchForm
  9 +
  10 +from accounts.models import User
  11 +from super_archives.models import Message
  12 +
  13 +
  14 +class ColabSearchForm(SearchForm):
  15 + q = forms.CharField(label=_('Search'), required=False)
  16 + order = forms.CharField(widget=forms.HiddenInput(), required=False)
  17 + type = forms.CharField(required=False, label=_(u'Type'))
  18 +
  19 + def search(self):
  20 + if not self.is_valid():
  21 + return self.no_query_found()
  22 +
  23 + if self.cleaned_data.get('q'):
  24 + q = unicodedata.normalize(
  25 + 'NFKD', unicode(self.cleaned_data.get('q'))
  26 + ).encode('ascii', 'ignore')
  27 + sqs = self.searchqueryset.auto_query(q)
  28 + else:
  29 + sqs = self.searchqueryset.all()
  30 +
  31 + if self.cleaned_data['type']:
  32 + "It will consider other types with a whitespace"
  33 + types = self.cleaned_data['type']
  34 + sqs = sqs.filter(type__in=types.split())
  35 +
  36 +
  37 + if self.cleaned_data['order']:
  38 + for option, dict_order in settings.ORDERING_DATA.items():
  39 + if self.cleaned_data['order'] == option:
  40 + if dict_order['fields']:
  41 + sqs = sqs.order_by(*dict_order['fields'])
  42 + # if self.cleaned_data['type'] == 'user':
  43 + # sqs = self.searchqueryset.models(User)
  44 + # elif self.cleaned_data['type'] in ['message', 'thread']:
  45 + # sqs = self.searchqueryset.models(Message)
  46 + # elif self.cleaned_data['type'] == 'wiki':
  47 + # sqs = self.searchqueryset.models(Wiki)
  48 + # elif self.cleaned_data['type'] == 'changeset':
  49 + # sqs = self.searchqueryset.models(Changeset)
  50 + # elif self.cleaned_data['type'] == 'ticket':
  51 + # sqs = self.searchqueryset.models(Ticket)
  52 + # else:
  53 + # sqs = self.searchqueryset.all()
  54 +
  55 +
  56 + if self.load_all:
  57 + sqs = sqs.load_all()
  58 +
  59 + return sqs
src/search/models.py 0 → 100644
@@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
  1 +from django.db import models
  2 +
  3 +# Create your models here.
src/search/tests.py 0 → 100644
@@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
  1 +from django.test import TestCase
  2 +
  3 +# Create your tests here.
src/search/urls.py 0 → 100644
@@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
  1 +from django.conf.urls import patterns, include, url
  2 +from haystack.query import SearchQuerySet
  3 +
  4 +from .forms import ColabSearchForm
  5 +from .views import ColabSearchView
  6 +
  7 +
  8 +urlpatterns = patterns('',
  9 + url(r'^$', ColabSearchView(
  10 + template='search/search.html',
  11 + searchqueryset=SearchQuerySet(),
  12 + form_class=ColabSearchForm,
  13 + ), name='haystack_search'),
  14 +)
src/search/views.py 0 → 100644
@@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
  1 +# -*- coding:utf-8 -*-
  2 +
  3 +from django.conf import settings
  4 +from haystack.views import SearchView
  5 +
  6 +
  7 +class ColabSearchView(SearchView):
  8 + def extra_context(self, *args, **kwargs):
  9 + # Retornar todos os campos de cada tipo a serem filtrados
  10 + # retornar os nomes dos campos
  11 + # retornar os ícones dos tipos
  12 +
  13 + # a critical point on the system
  14 + types = {
  15 + 'wiki': {
  16 + 'icon': 'file',
  17 + 'fields': [
  18 + 'title', 'description', 'author', 'collaborators',
  19 + 'created', 'modified',
  20 + ],
  21 + },
  22 + 'discussion': {
  23 + 'icon': 'thread',
  24 + 'fields': [
  25 + 'title', 'description', 'created', 'modified', 'author',
  26 + 'tag',
  27 + ],
  28 + },
  29 + 'ticket': {
  30 + 'icon': 'ticket',
  31 + 'fields': [
  32 + 'title', 'description', 'milestone', 'priority',
  33 + 'component', 'version', 'severity', 'reporter', 'author',
  34 + 'status', 'keywords', 'collaborators', 'created',
  35 + 'modified',
  36 + ],
  37 + },
  38 + 'changeset': {
  39 + 'icon': 'changeset',
  40 + 'fields': [
  41 + 'title', 'author', 'description', 'repository_name',
  42 + 'created', 'modified',
  43 + ],
  44 + },
  45 + 'user': {
  46 + 'icon': 'user',
  47 + 'fields': [
  48 + 'title', 'description', 'username', 'name',
  49 + 'email', 'institution', 'role', 'google_talk', 'webpage',
  50 + ],
  51 + },
  52 + }
  53 + types = self.form.cleaned_data['type']
  54 + return dict(
  55 + types=types.split(),
  56 + types_str=types,
  57 + order_data=settings.ORDERING_DATA
  58 + )
src/static/css/screen.css
@@ -374,3 +374,8 @@ ul.emails { @@ -374,3 +374,8 @@ ul.emails {
374 } 374 }
375 375
376 /* end user profile update */ 376 /* end user profile update */
  377 +
  378 +/* search highlighting */
  379 +span.highlighted {
  380 + background-color: yellow;
  381 +}
src/super_archives/management/commands/import_emails.py
@@ -21,35 +21,35 @@ class Command(BaseCommand, object): @@ -21,35 +21,35 @@ class Command(BaseCommand, object):
21 """Get emails from mailman archives and import them in the django db. """ 21 """Get emails from mailman archives and import them in the django db. """
22 22
23 help = __doc__ 23 help = __doc__
24 - 24 +
25 default_archives_path = '/var/lib/mailman/archives/private' 25 default_archives_path = '/var/lib/mailman/archives/private'
26 RE_SUBJECT_CLEAN = re.compile('((re|res|fw|fwd|en|enc):)|\[.*?\]', 26 RE_SUBJECT_CLEAN = re.compile('((re|res|fw|fwd|en|enc):)|\[.*?\]',
27 re.IGNORECASE) 27 re.IGNORECASE)
28 THREAD_CACHE = {} 28 THREAD_CACHE = {}
29 EMAIL_ADDR_CACHE = {} 29 EMAIL_ADDR_CACHE = {}
30 - 30 +
31 # A new command line option to get the dump file to parse. 31 # A new command line option to get the dump file to parse.
32 option_list = BaseCommand.option_list + ( 32 option_list = BaseCommand.option_list + (
33 make_option('--archives_path', 33 make_option('--archives_path',
34 dest='archives_path', 34 dest='archives_path',
35 - help='Path of email archives to be imported. (default: %s)' % 35 + help='Path of email archives to be imported. (default: %s)' %
36 default_archives_path, 36 default_archives_path,
37 default=default_archives_path), 37 default=default_archives_path),
38 - 38 +
39 make_option('--exclude-list', 39 make_option('--exclude-list',
40 dest='exclude_lists', 40 dest='exclude_lists',
41 - help=("Mailing list that won't be imported. It can be used many" 41 + help=("Mailing list that won't be imported. It can be used many"
42 "times for more than one list."), 42 "times for more than one list."),
43 action='append', 43 action='append',
44 default=None), 44 default=None),
45 - 45 +
46 make_option('--all', 46 make_option('--all',
47 dest='all', 47 dest='all',
48 help='Import all messages (default: False)', 48 help='Import all messages (default: False)',
49 action="store_true", 49 action="store_true",
50 default=False), 50 default=False),
51 ) 51 )
52 - 52 +
53 def __init__(self, *args, **kwargs): 53 def __init__(self, *args, **kwargs):
54 super(Command, self).__init__(*args, **kwargs) 54 super(Command, self).__init__(*args, **kwargs)
55 55
@@ -68,18 +68,18 @@ class Command(BaseCommand, object): @@ -68,18 +68,18 @@ class Command(BaseCommand, object):
68 68
69 Yield: An instance of `mailbox.mboxMessage` for each email in the 69 Yield: An instance of `mailbox.mboxMessage` for each email in the
70 file. 70 file.
71 - 71 +
72 """ 72 """
73 self.log("Parsing email dump: %s." % email_filename) 73 self.log("Parsing email dump: %s." % email_filename)
74 mbox = mailbox.mbox(email_filename, factory=CustomMessage) 74 mbox = mailbox.mbox(email_filename, factory=CustomMessage)
75 - 75 +
76 # Get each email from mbox file 76 # Get each email from mbox file
77 # 77 #
78 # The following implementation was used because the object 78 # The following implementation was used because the object
79 - # mbox does not support slicing. Converting the object to a  
80 - # tuple (as represented in the code down here) was a valid 79 + # mbox does not support slicing. Converting the object to a
  80 + # tuple (as represented in the code down here) was a valid
81 # option but its performance was too poor. 81 # option but its performance was too poor.
82 - # 82 + #
83 #for message in tuple(mbox)[index:]: 83 #for message in tuple(mbox)[index:]:
84 # yield message 84 # yield message
85 # 85 #
@@ -90,8 +90,8 @@ class Command(BaseCommand, object): @@ -90,8 +90,8 @@ class Command(BaseCommand, object):
90 90
91 def get_emails(self, mailinglist_dir, all, exclude_lists): 91 def get_emails(self, mailinglist_dir, all, exclude_lists):
92 """Generator function that get the emails from each mailing 92 """Generator function that get the emails from each mailing
93 - list dump dirctory. If `all` is set to True all the emails in the  
94 - mbox will be imported if not it will just resume from the last 93 + list dump dirctory. If `all` is set to True all the emails in the
  94 + mbox will be imported if not it will just resume from the last
95 message previously imported. The lists set in `exclude_lists` 95 message previously imported. The lists set in `exclude_lists`
96 won't be imported. 96 won't be imported.
97 97
@@ -99,20 +99,20 @@ class Command(BaseCommand, object): @@ -99,20 +99,20 @@ class Command(BaseCommand, object):
99 99
100 """ 100 """
101 self.log("Getting emails dumps from: %s" % mailinglist_dir) 101 self.log("Getting emails dumps from: %s" % mailinglist_dir)
102 - 102 +
103 # Get the list of directories ending with .mbox 103 # Get the list of directories ending with .mbox
104 - mailing_lists_mboxes = (mbox for mbox in os.listdir(mailinglist_dir) 104 + mailing_lists_mboxes = (mbox for mbox in os.listdir(mailinglist_dir)
105 if mbox.endswith('.mbox')) 105 if mbox.endswith('.mbox'))
106 - 106 +
107 # Get messages from each mbox 107 # Get messages from each mbox
108 for mbox in mailing_lists_mboxes: 108 for mbox in mailing_lists_mboxes:
109 mbox_path = os.path.join(mailinglist_dir, mbox, mbox) 109 mbox_path = os.path.join(mailinglist_dir, mbox, mbox)
110 mailinglist_name = mbox.split('.')[0] 110 mailinglist_name = mbox.split('.')[0]
111 - 111 +
112 # Check if the mailinglist is set not to be imported 112 # Check if the mailinglist is set not to be imported
113 if exclude_lists and mailinglist_name in exclude_lists: 113 if exclude_lists and mailinglist_name in exclude_lists:
114 continue 114 continue
115 - 115 +
116 # Find the index of the last imported message 116 # Find the index of the last imported message
117 if all: 117 if all:
118 n_msgs = 0 118 n_msgs = 0
@@ -123,13 +123,13 @@ class Command(BaseCommand, object): @@ -123,13 +123,13 @@ class Command(BaseCommand, object):
123 n_msgs = mailinglist.last_imported_index 123 n_msgs = mailinglist.last_imported_index
124 except MailingList.DoesNotExist: 124 except MailingList.DoesNotExist:
125 n_msgs = 0 125 n_msgs = 0
126 - 126 +
127 for index, msg in self.parse_emails(mbox_path, n_msgs): 127 for index, msg in self.parse_emails(mbox_path, n_msgs):
128 yield mailinglist_name, msg, index 128 yield mailinglist_name, msg, index
129 129
130 def get_thread(self, email, mailinglist): 130 def get_thread(self, email, mailinglist):
131 """Group messages by thread looking for similar subjects""" 131 """Group messages by thread looking for similar subjects"""
132 - 132 +
133 subject_slug = slugify(email.subject_clean) 133 subject_slug = slugify(email.subject_clean)
134 thread = self.THREAD_CACHE.get(subject_slug, {}).get(mailinglist.id) 134 thread = self.THREAD_CACHE.get(subject_slug, {}).get(mailinglist.id)
135 if thread is None: 135 if thread is None:
@@ -137,27 +137,27 @@ class Command(BaseCommand, object): @@ -137,27 +137,27 @@ class Command(BaseCommand, object):
137 mailinglist=mailinglist, 137 mailinglist=mailinglist,
138 subject_token=subject_slug 138 subject_token=subject_slug
139 )[0] 139 )[0]
140 - 140 +
141 if self.THREAD_CACHE.get(subject_slug) is None: 141 if self.THREAD_CACHE.get(subject_slug) is None:
142 self.THREAD_CACHE[subject_slug] = dict() 142 self.THREAD_CACHE[subject_slug] = dict()
143 self.THREAD_CACHE[subject_slug][mailinglist.id] = thread 143 self.THREAD_CACHE[subject_slug][mailinglist.id] = thread
144 144
145 thread.latest_message = email 145 thread.latest_message = email
146 - thread.save() 146 + thread.save()
147 return thread 147 return thread
148 - 148 +
149 def save_email(self, list_name, email_msg, index): 149 def save_email(self, list_name, email_msg, index):
150 """Save email message into the database.""" 150 """Save email message into the database."""
151 - 151 +
152 # Update last imported message into the DB 152 # Update last imported message into the DB
153 mailinglist, created = MailingList.objects.get_or_create(name=list_name) 153 mailinglist, created = MailingList.objects.get_or_create(name=list_name)
154 mailinglist.last_imported_index = index 154 mailinglist.last_imported_index = index
155 -  
156 - if created: 155 +
  156 + if created:
157 # if the mailinglist is newly created it's sure that the message 157 # if the mailinglist is newly created it's sure that the message
158 # is not in the DB yet. 158 # is not in the DB yet.
159 self.create_email(mailinglist, email_msg) 159 self.create_email(mailinglist, email_msg)
160 - 160 +
161 else: 161 else:
162 # If the message is already at the database don't do anything 162 # If the message is already at the database don't do anything
163 try: 163 try:
@@ -165,11 +165,11 @@ class Command(BaseCommand, object): @@ -165,11 +165,11 @@ class Command(BaseCommand, object):
165 message_id=email_msg.get('Message-ID'), 165 message_id=email_msg.get('Message-ID'),
166 thread__mailinglist=mailinglist 166 thread__mailinglist=mailinglist
167 ) 167 )
168 - 168 +
169 except Message.DoesNotExist: 169 except Message.DoesNotExist:
170 self.create_email(mailinglist, email_msg) 170 self.create_email(mailinglist, email_msg)
171 -  
172 - mailinglist.save() 171 +
  172 + mailinglist.save()
173 173
174 def create_email(self, mailinglist, email_msg): 174 def create_email(self, mailinglist, email_msg):
175 175
@@ -198,59 +198,59 @@ class Command(BaseCommand, object): @@ -198,59 +198,59 @@ class Command(BaseCommand, object):
198 email.thread = self.get_thread(email, mailinglist) 198 email.thread = self.get_thread(email, mailinglist)
199 email.save() 199 email.save()
200 200
201 - @transaction.commit_manually 201 + @transaction.commit_manually
202 def import_emails(self, archives_path, all, exclude_lists=None): 202 def import_emails(self, archives_path, all, exclude_lists=None):
203 - """Get emails from the filesystem from the `archives_path`  
204 - and store them into the database. If `all` is set to True all  
205 - the filesystem storage will be imported otherwise the  
206 - importation will resume from the last message previously 203 + """Get emails from the filesystem from the `archives_path`
  204 + and store them into the database. If `all` is set to True all
  205 + the filesystem storage will be imported otherwise the
  206 + importation will resume from the last message previously
207 imported. The lists set in `exclude_lists` won't be imported. 207 imported. The lists set in `exclude_lists` won't be imported.
208 - 208 +
209 """ 209 """
210 - 210 +
211 count = 0 211 count = 0
212 email_generator = self.get_emails(archives_path, all, exclude_lists) 212 email_generator = self.get_emails(archives_path, all, exclude_lists)
213 for mailinglist_name, msg, index in email_generator: 213 for mailinglist_name, msg, index in email_generator:
214 try: 214 try:
215 self.save_email(mailinglist_name, msg, index) 215 self.save_email(mailinglist_name, msg, index)
216 except: 216 except:
217 - # This anti-pattern is needed to avoid the transations to 217 + # This anti-pattern is needed to avoid the transations to
218 # get stuck in case of errors. 218 # get stuck in case of errors.
219 transaction.rollback() 219 transaction.rollback()
220 raise 220 raise
221 - 221 +
222 count += 1 222 count += 1
223 if count % 1000 == 0: 223 if count % 1000 == 0:
224 transaction.commit() 224 transaction.commit()
225 - 225 +
226 transaction.commit() 226 transaction.commit()
227 - 227 +
228 def handle(self, *args, **options): 228 def handle(self, *args, **options):
229 """Main command method.""" 229 """Main command method."""
230 - 230 +
231 lock_file = '/var/lock/colab/import_emails.lock' 231 lock_file = '/var/lock/colab/import_emails.lock'
232 - 232 +
233 # Already running, so quit 233 # Already running, so quit
234 if os.path.exists(lock_file): 234 if os.path.exists(lock_file):
235 self.log(("This script is already running. (If your are sure it's " 235 self.log(("This script is already running. (If your are sure it's "
236 "not please delete the lock file in %s')") % lock_file) 236 "not please delete the lock file in %s')") % lock_file)
237 sys.exit(0) 237 sys.exit(0)
238 - 238 +
239 if not os.path.exists(os.path.dirname(lock_file)): 239 if not os.path.exists(os.path.dirname(lock_file)):
240 os.mkdir(os.path.dirname(lock_file), 0755) 240 os.mkdir(os.path.dirname(lock_file), 0755)
241 - 241 +
242 run_lock = file(lock_file, 'w') 242 run_lock = file(lock_file, 'w')
243 run_lock.close() 243 run_lock.close()
244 - 244 +
245 archives_path = options.get('archives_path') 245 archives_path = options.get('archives_path')
246 self.log('Using archives_path `%s`' % self.default_archives_path) 246 self.log('Using archives_path `%s`' % self.default_archives_path)
247 - 247 +
248 if not os.path.exists(archives_path): 248 if not os.path.exists(archives_path):
249 raise CommandError('archives_path (%s) does not exist' % 249 raise CommandError('archives_path (%s) does not exist' %
250 archives_path) 250 archives_path)
251 -  
252 - self.import_emails(archives_path, 251 +
  252 + self.import_emails(archives_path,
253 options.get('all'), options.get('exclude_lists')) 253 options.get('all'), options.get('exclude_lists'))
254 - 254 +
255 os.remove(lock_file) 255 os.remove(lock_file)
256 - 256 +
src/super_archives/models.py
@@ -79,6 +79,9 @@ class MailingList(models.Model): @@ -79,6 +79,9 @@ class MailingList(models.Model):
79 logo = models.FileField(upload_to='list_logo') #TODO 79 logo = models.FileField(upload_to='list_logo') #TODO
80 last_imported_index = models.IntegerField(default=0) 80 last_imported_index = models.IntegerField(default=0)
81 81
  82 + def get_absolute_url(self):
  83 + return u'{}?list={}'.format(reverse('thread_list'), self.name)
  84 +
82 def __unicode__(self): 85 def __unicode__(self):
83 return self.name 86 return self.name
84 87
@@ -125,6 +128,10 @@ class Thread(models.Model): @@ -125,6 +128,10 @@ class Thread(models.Model):
125 verbose_name_plural = _(u"Threads") 128 verbose_name_plural = _(u"Threads")
126 unique_together = ('subject_token', 'mailinglist') 129 unique_together = ('subject_token', 'mailinglist')
127 130
  131 + @models.permalink
  132 + def get_absolute_url(self):
  133 + return ('thread_view', [self.mailinglist, self.subject_token])
  134 +
128 def update_keywords(self): 135 def update_keywords(self):
129 blocks = MessageBlock.objects.filter(message__thread__pk=self.pk, 136 blocks = MessageBlock.objects.filter(message__thread__pk=self.pk,
130 is_reply=False) 137 is_reply=False)
src/super_archives/search_indexes.py 0 → 100644
@@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
  1 +# -*- coding: utf-8 -*-
  2 +
  3 +from haystack import indexes
  4 +
  5 +from .models import Thread
  6 +
  7 +
  8 +class ThreadIndex(indexes.SearchIndex, indexes.Indexable):
  9 + # common fields
  10 + text = indexes.CharField(document=True, use_template=True)
  11 + url = indexes.CharField(model_attr='get_absolute_url', null=True)
  12 + title = indexes.CharField(model_attr='latest_message__subject_clean')
  13 + description = indexes.CharField(use_template=True)
  14 + created = indexes.DateTimeField()
  15 + modified = indexes.DateTimeField(
  16 + model_attr='latest_message__received_time'
  17 + )
  18 + author = indexes.CharField(null=True)
  19 + author_url = indexes.CharField(null=True)
  20 + type = indexes.CharField()
  21 + icon_name = indexes.CharField()
  22 + tag = indexes.CharField(model_attr='mailinglist__name')
  23 +
  24 + mailinglist_url = indexes.CharField(
  25 + model_attr='mailinglist__get_absolute_url'
  26 + )
  27 +
  28 + def get_model(self):
  29 + return Thread
  30 +
  31 + def get_updated_field(self):
  32 + return 'received_time'
  33 +
  34 + def prepare_author(self, obj):
  35 + return obj.message_set.first().from_address.get_full_name()
  36 +
  37 + def prepare_author_url(self, obj):
  38 + first_message = obj.message_set.first()
  39 + if first_message.from_address.user:
  40 + return first_message.from_address.user.get_absolute_url()
  41 + return None
  42 +
  43 + def prepare_created(self, obj):
  44 + return obj.message_set.first().received_time
  45 +
  46 + def prepare_icon_name(self, obj):
  47 + return u'envelope'
  48 +
  49 + def prepare_type(self, obj):
  50 + return u'thread'
  51 +
  52 + def index_queryset(self, using=None):
  53 + return self.get_model().objects.filter(
  54 + spam=False
  55 + ).exclude(subject_token='')
src/super_archives/templates/message-list.html
@@ -12,18 +12,24 @@ @@ -12,18 +12,24 @@
12 12
13 <h4>{% trans "Sort by" %}</h4> 13 <h4>{% trans "Sort by" %}</h4>
14 <ul class="unstyled-list"> 14 <ul class="unstyled-list">
15 - <li {% ifequal order_by "hottest" %} title="{% trans "Remove filter" %}" {% endifequal %}>  
16 - <span class="glyphicon glyphicon-chevron-right"></span> <a href="{% ifequal order_by "hottest" %} {% append_to_get order="",p=1 %} {% else %} {% append_to_get order='hottest',p=1 %} {% endifequal %}">{% trans "Relevance" %}</a></li>  
17 - <li {% ifequal order_by "latest" %} title="{% trans "Remove filter" %}" {% endifequal %}>  
18 - <span class="glyphicon glyphicon-chevron-right"></span> <a href="{% ifequal order_by "latest" %} {% append_to_get order="",p=1 %} {% else %} {% append_to_get order='latest',p=1 %} {% endifequal %}">  
19 - {% trans "Recent activity" %}</a></li> 15 + {% for option, dict_order in order_data.items %}
  16 + <li>
  17 + <span class="glyphicon glyphicon-chevron-right"></span>
  18 + <a href="{% append_to_get order=option p=1 %}">
  19 + {% ifequal request.GET.order option %}
  20 + {% blocktrans with name=dict_order.name %}<strong>{{ name }}</strong>{% endblocktrans %}</a>
  21 + {% else %}
  22 + {% blocktrans with name=dict_order.name %}{{ name }}{% endblocktrans %}</a>
  23 + {% endifequal %}
  24 + </li>
  25 + {% endfor %}
20 </ul> 26 </ul>
21 27
22 <h4>{% trans "Lists" %}</h4> 28 <h4>{% trans "Lists" %}</h4>
23 <ul class="unstyled-list"> 29 <ul class="unstyled-list">
24 {% for list in lists %} 30 {% for list in lists %}
25 <li {% if list.name == selected_list %} title="{% trans "Remove filter" %}" class="selected" {% endif %}> 31 <li {% if list.name == selected_list %} title="{% trans "Remove filter" %}" class="selected" {% endif %}>
26 - <span class="glyphicon {% if list.name == selected_list %}glyphicon-remove{% else %}glyphicon-chevron-right{% endif %}"></span> <a href="{% ifnotequal list.name selected_list %} {% append_to_get list=list.name,p=1 %} {% else %} {% append_to_get list="",p=1 %} 32 + <span class="glyphicon {% if list.name == selected_list %}glyphicon-remove{% else %}glyphicon-chevron-right{% endif %}"></span> <a href="{% ifnotequal list.name selected_list %} {% append_to_get list=list.name p=1 %} {% else %} {% append_to_get list="" p=1 %}
27 {% endifnotequal %}">{{ list.name }}</a></li> 33 {% endifnotequal %}">{{ list.name }}</a></li>
28 {% endfor %} 34 {% endfor %}
29 </ul> 35 </ul>
src/super_archives/templates/search/indexes/super_archives/thread_description.txt 0 → 100644
@@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
  1 +{% for message in object.message_set.iterator %}
  2 + {% if not spam %}
  3 + {{ message.subject_clean }}
  4 + {{ message.subject_clean|slugify }}
  5 + {{ message.body }}
  6 + {{ message.body|slugify }}
  7 + {{ message.from_address.get_full_name }}
  8 + {{ message.from_address.get_full_name|slugify }}
  9 + {% endif %}
  10 +{% endfor %}
src/super_archives/templates/search/indexes/super_archives/thread_text.txt 0 → 100644
@@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
  1 +{{ object.thread.mailinglist.name }}
  2 +{{ object.thread.mailinglist.name|slugify }}
  3 +{{ object.thread.subject_token }}
  4 +
  5 +{{ object.body }}
  6 +{{ object.body|slugify }}
  7 +{{ object.subject_clean }}
  8 +{{ object.subject_clean|slugify }}
  9 +
  10 +{{ object.from_address.get_full_name }}
  11 +
  12 +{% for message in object.message_set.iterator %}
  13 + {% if not spam %}
  14 + {{ message.subject_clean }}
  15 + {{ message.subject_clean|slugify }}
  16 + {{ message.body }}
  17 + {{ message.body|slugify }}
  18 + {{ message.from_address.get_full_name }}
  19 + {{ message.from_address.get_full_name|slugify }}
  20 + {% endif %}
  21 +{% endfor %}
src/super_archives/templatetags/append_to_get.py
@@ -1,56 +0,0 @@ @@ -1,56 +0,0 @@
1 -  
2 -import urllib  
3 -from django import template  
4 -  
5 -register = template.Library()  
6 -  
7 -"""  
8 -Decorator to facilitate template tag creation  
9 -"""  
10 -def easy_tag(func):  
11 - """deal with the repetitive parts of parsing template tags"""  
12 - def inner(parser, token):  
13 - #print token  
14 - try:  
15 - return func(*token.split_contents())  
16 - except TypeError:  
17 - raise template.TemplateSyntaxError('Bad arguments for tag "%s"' %  
18 - token.split_contents()[0])  
19 - inner.__name__ = func.__name__  
20 - inner.__doc__ = inner.__doc__  
21 - return inner  
22 -  
23 -  
24 -class AppendGetNode(template.Node):  
25 - def __init__(self, dict):  
26 - self.dict_pairs = {}  
27 - for pair in dict.split(','):  
28 - pair = pair.split('=')  
29 - self.dict_pairs[pair[0]] = template.Variable(pair[1])  
30 -  
31 - def render(self, context):  
32 - get = context['request'].GET.copy()  
33 -  
34 - for key in self.dict_pairs:  
35 - get[key] = self.dict_pairs[key].resolve(context)  
36 -  
37 - path = context['request'].META['PATH_INFO']  
38 -  
39 - if len(get):  
40 - # Convert all unicode objects in the get dict to  
41 - # str (utf-8 encoded)  
42 - get_utf_encoded = {}  
43 - for (key, value) in get.items():  
44 - if isinstance(value, unicode):  
45 - value = value.encode('utf-8')  
46 - get_utf_encoded.update({key: value})  
47 - get_utf_encoded = dict(get_utf_encoded)  
48 -  
49 - path = '?' + urllib.urlencode(get_utf_encoded)  
50 -  
51 - return path  
52 -  
53 -@register.tag()  
54 -@easy_tag  
55 -def append_to_get(_tag_name, dict):  
56 - return AppendGetNode(dict)  
src/super_archives/templatetags/urlutils.py 0 → 100644
@@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
  1 +# -*- coding: utf-8 -*-
  2 +
  3 +from django import template
  4 +
  5 +from super_archives.utils import url
  6 +
  7 +register = template.Library()
  8 +
  9 +
  10 +@register.simple_tag(takes_context=True)
  11 +def append_to_get(context, **kwargs):
  12 + return url.append_to_get(
  13 + context['request'].META['PATH_INFO'],
  14 + context['request'].META['QUERY_STRING'],
  15 + **kwargs
  16 + )
  17 +
  18 +@register.simple_tag(takes_context=True)
  19 +def pop_from_get(context, **kwargs):
  20 + return url.pop_from_get(
  21 + context['request'].META['PATH_INFO'],
  22 + context['request'].META['QUERY_STRING'],
  23 + **kwargs
  24 + )
src/super_archives/utils/url.py 0 → 100644
@@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
  1 +# -*- coding: utf-8 -*-
  2 +
  3 +def append_to_get(path, query=None, **kwargs):
  4 +# Getting the path with the query
  5 + current_url = u'{}?{}'.format(
  6 + path,
  7 + query,
  8 + )
  9 +
  10 + if kwargs and query:
  11 + current_url += '&'
  12 +
  13 + for key, value in kwargs.items():
  14 + # get the key, value to check if the pair exists in the query
  15 + new = u'{}={}'.format(key, value)
  16 +
  17 + if new in current_url:
  18 + continue
  19 +
  20 + if key not in current_url:
  21 + current_url += u'{}={}&'.format(key, value)
  22 + continue
  23 +
  24 + parse_url = current_url.split(key)
  25 +
  26 + if len(parse_url) > 2:
  27 + continue
  28 +
  29 + if unicode(value) in parse_url[1][1:]:
  30 + continue
  31 +
  32 + check_kwargs_values = [
  33 + False for value in kwargs.values()
  34 + if unicode(value) not in parse_url[1]
  35 + ]
  36 +
  37 + if not all(check_kwargs_values):
  38 + list_remaining = parse_url[1][1:].split('&')
  39 + real_remaining = u''
  40 +
  41 + if len(list_remaining) >= 2:
  42 + real_remaining = u'&'.join(list_remaining[1:])
  43 +
  44 + current_url = u'{url}{key}={value}&{remaining}'.format(
  45 + url=parse_url[0],
  46 + key=key,
  47 + value=value,
  48 + remaining=real_remaining,
  49 + )
  50 + continue
  51 +
  52 + current_url = u'{url}{key}={value}+{remaining_get}'.format(
  53 + url=parse_url[0],
  54 + key=key,
  55 + value=value,
  56 + remaining_get=parse_url[1][1:],
  57 + )
  58 + if current_url[-1] == '&':
  59 + return current_url[:-1]
  60 + return current_url
  61 +
  62 +
  63 +def pop_from_get(path, query=None, **kwargs):
  64 + # Getting the path with the query
  65 + print query
  66 +
  67 + current_url = u'{}?{}'.format(
  68 + path,
  69 + query,
  70 + )
  71 + for key, value in kwargs.items():
  72 + popitem = u'{}={}'.format(key, value)
  73 + if query == popitem:
  74 + return path
  75 +
  76 + if key not in current_url:
  77 + return current_url
  78 +
  79 + first_path, end_path = current_url.split(key)
  80 + end_path_without_element = end_path.split(value, 1)
  81 + path_list = first_path + end_path_without_element
  82 + print path_list
  83 + return u''.join(path_list)
src/super_archives/views.py
@@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
3 import smtplib 3 import smtplib
4 4
5 from django import http 5 from django import http
  6 +from django.conf import settings
6 from django.contrib import messages 7 from django.contrib import messages
7 from django.db import IntegrityError 8 from django.db import IntegrityError
8 from django.views.generic import View 9 from django.views.generic import View
@@ -85,7 +86,7 @@ def list_messages(request): @@ -85,7 +86,7 @@ def list_messages(request):
85 'n_results': paginator.count, 86 'n_results': paginator.count,
86 'threads': threads, 87 'threads': threads,
87 'selected_list': selected_list, 88 'selected_list': selected_list,
88 - 'order_by': order_by, 89 + 'order_data': settings.ORDERING_DATA,
89 } 90 }
90 return render(request, 'message-list.html', template_data) 91 return render(request, 'message-list.html', template_data)
91 92
src/templates/base.html
@@ -111,7 +111,7 @@ @@ -111,7 +111,7 @@
111 {% endif %} 111 {% endif %}
112 </ul> 112 </ul>
113 113
114 - <form action="/search/" method="GET" id="search-form" class="navbar-form navbar-right hidden-xs hidden-sm" role="search"> 114 + <form action="{% url 'haystack_search' %}" method="GET" id="search-form" class="navbar-form navbar-right hidden-xs hidden-sm" role="search">
115 <div class="form-group"> 115 <div class="form-group">
116 <label class="sr-only" for="header-searchbox">{% trans 'Search here...' %}</label> 116 <label class="sr-only" for="header-searchbox">{% trans 'Search here...' %}</label>
117 <input name="q" id="header-searchbox" 117 <input name="q" id="header-searchbox"
src/templates/home.html
@@ -27,12 +27,12 @@ @@ -27,12 +27,12 @@
27 title="{% trans 'RSS - Latest collaborations' %}"> 27 title="{% trans 'RSS - Latest collaborations' %}">
28 </a> 28 </a>
29 <ul class="message-list"> 29 <ul class="message-list">
30 - {% for doc in latest_docs %}  
31 - {% include "message-preview.html" %} 30 + {% for result in latest_results %}
  31 + {% include "search/preview-search.html" %}
32 {% endfor %} 32 {% endfor %}
33 </ul> 33 </ul>
34 <a class="column-align" 34 <a class="column-align"
35 - href="{% url 'search' %}?o=modified+desc"> 35 + href="{% url 'haystack_search' %}?order=latest">
36 {% trans "View more collaborations..." %} 36 {% trans "View more collaborations..." %}
37 </a> 37 </a>
38 <div>&nbsp;</div> 38 <div>&nbsp;</div>
src/templates/search/preview-search.html 0 → 100644
@@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
  1 +{% load i18n %}
  2 +{% load highlight %}
  3 +
  4 +<li class="preview-message">
  5 +<span class="glyphicon glyphicon-{{ result.icon_name }}" title="{{ result.type }}"></span>
  6 +
  7 +{% if result.tag %}
  8 +<a href="{% firstof result.mailinglist_url result.url %}">
  9 + <span class="label label-primary">{{ result.tag }}</span>
  10 +</a>
  11 +{% endif %}
  12 +
  13 +{% if result.title %}
  14 + <a href="{{ result.url }}" {% if result.description %}title="{{ result.description|escape|truncatechars:200 }}"{% endif %}>
  15 + <span class="subject">
  16 + <!-- a striptags filter was raising an error here because using with highlight -->
  17 + {% if query %}
  18 + {% highlight result.title with query max_length "1000" %}
  19 + {% else %}
  20 + {{ result.title }}
  21 + {% endif %}
  22 + </span>
  23 + </a>
  24 +{% endif %}
  25 +
  26 +{% if result.description %}
  27 + <!-- a striptags filter was raising an error here because using with highlight -->
  28 + <span class="quiet">- {% if query %}{% highlight result.description with query max_length "150" %}{% else %}{{ result.description }}{% endif %}</span>
  29 +{% endif %}
  30 +
  31 +{% if result.author or result.modified %}
  32 + <div class="quiet">
  33 + {% if result.author %}
  34 + <span class="pull-left">{% trans "by" %}
  35 + {% if result.author and result.author_url %}
  36 + <a href="{{ result.author_url }}">
  37 + {% if query %}
  38 + {% highlight result.author with query %}
  39 + {% else %}
  40 + {{ result.author }}
  41 + {% endif %}
  42 + </a>
  43 + {% else %}
  44 + <span>{{ result.author }}</span>
  45 + {% endif %}
  46 + </span>
  47 + {% endif %}
  48 + {% if result.modified %}
  49 + <span class="pull-right">{{ result.modified|timesince }} {% trans "ago" %}</span>
  50 + {% endif %}
  51 + </div>
  52 +{% endif %}
  53 +</li>
src/templates/search/search-message-preview.html 0 → 100644
@@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
  1 +{% load i18n %}
  2 +
  3 +<span class="glyphicon glyphicon-envelope" title="{{ result.type }}"></span>
  4 +
  5 +{% if result.mailinglist %}
  6 + <a href="{% url 'super_archives.views.list_messages' %}?list={{ result.mailinglist }}">
  7 + <span class="label label-primary">{{ result.mailinglist }}</span>
  8 + </a>
  9 +{% endif %}
  10 +
  11 +<span class="subject">
  12 + <a href="{{ result.url }}#msg-{{ result.pk }}"
  13 + title="{% filter striptags|truncatewords:50 %}{{ result.description|escape }}{% endfilter %}">
  14 + {{ result.title }}
  15 + </a>
  16 +</span>
  17 +
  18 +<span class="quiet">- {{ result.description|striptags }}</span>
src/templates/search/search-revision-preview.html 0 → 100644
@@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
  1 +{% load i18n %}
  2 +
  3 +<span class="glyphicon glyphicon-align-right" title="{{ result.type }}"></span>
  4 +
  5 +<span class="subject">
  6 + <a href="{{ result.url }}">{{ result.repository_name }} [{{ result.revision }}]</a>
  7 +</span>
  8 +
  9 +<span class="quiet">{% if result.message %}- {{ result.message }}{% endif %}</span>
src/templates/search/search-ticket-preview.html 0 → 100644
@@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
  1 +{% load i18n %}
  2 +{% load highlight %}
  3 +
  4 +<span class="glyphicon glyphicon-tag" title="{{ result.type }}"></span>
  5 +
  6 +<a href="{{ result.url }}" title="{{ result.description|escape }}">
  7 +{% if result.status %}
  8 + <span class="label label-primary">{{ result.status }}</span>
  9 +{% endif %}
  10 +
  11 +<span class="subject">
  12 + #{{ result.pk }} - {% filter striptags|truncatewords:50 %}{{ result.summary|escape }}{% endfilter %}
  13 + </a>
  14 +</span>
  15 +
  16 +<span class="quiet">- {% highlight result.description with query max_length "150" %}</span>
src/templates/search/search-user-preview.html 0 → 100644
@@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
  1 +{% load i18n %}
  2 +
  3 +<span class="glyphicon glyphicon-user" title="{{ result.type }}"></span>
  4 +
  5 +<span class="subject">
  6 + <a href="{% url 'user_profile' result.username %}">{{ result.name }}</a>
  7 +</span>
  8 +
  9 +<span class="quiet">{% if result.institution %}- {{ result.institution }}{% endif %}{% if result.role %} - {{ result.role }}{% endif %}</span>
src/templates/search/search-wiki-preview.html 0 → 100644
@@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
  1 +{% load i18n %}
  2 +
  3 +<span class="glyphicon glyphicon-file" title="{{ result.type }}"></span>
  4 +
  5 +<span class="subject">
  6 + <a href="{{ result.url }}">{{ result.name }}</a>
  7 +</span>
  8 +
  9 +<span class="quiet">{% if result.wiki_text %}- {{ result.wiki_text|truncatechars:150 }}{% elif result.comment %}- {{ result.comment|truncatechars:150 }}{% endif %}</span>
src/templates/search/search.html 0 → 100644
@@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
  1 +{% extends "base.html" %}
  2 +{% load i18n %}
  3 +{% load urlutils %}
  4 +{% load highlight %}
  5 +
  6 +{% block main-content %}
  7 + <div class="row">
  8 + <div class="col-lg-2">
  9 + <h2>{% trans "Search" %}</h2>
  10 + </div>
  11 + <span class="pull-right quiet">
  12 + {{ page.paginator.count }} {% trans "documents found" %}
  13 + </span>
  14 + </div>
  15 +
  16 + <hr/>
  17 +
  18 + <div class="row">
  19 + <div id="filters" class="hidden-xs hidden-sm col-md-2 col-lg-2">
  20 + <h3>{% trans "Filters" %}</h3>
  21 +
  22 + <h4>{% trans "Sort by" %}</h4>
  23 + <ul class="unstyled-list">
  24 + {% for option, dict_order in order_data.items %}
  25 + <li>
  26 + <span class="glyphicon glyphicon-chevron-right"></span>
  27 + <a href="{% append_to_get order=option p=1 %}">
  28 + {% ifequal request.GET.order option %}
  29 + {% blocktrans with name=dict_order.name %}<strong>{{ name }}</strong>{% endblocktrans %}
  30 + {% else %}
  31 + {% blocktrans with name=dict_order.name %}{{ name }}{% endblocktrans %}
  32 + {% endifequal %}
  33 + </a>
  34 + </li>
  35 + {% endfor %}
  36 + </ul>
  37 +
  38 + <h4>{% trans "Types" %}</h4>
  39 +
  40 + <ul class="unstyled-list">
  41 + <li>
  42 + <span class="glyphicon glyphicon-file"></span>
  43 + <a href="{% append_to_get type='wiki' %}">{% trans "Wiki" %}</a>
  44 + </li>
  45 + <li>
  46 + <span class="glyphicon glyphicon-envelope"></span>
  47 + <a href="{% append_to_get type='thread' %}">{% trans "Discussion" %}</a>
  48 + </li>
  49 + <li>
  50 + <span class="glyphicon glyphicon-tag"></span>
  51 + <a href="{% append_to_get type='ticket' %}">{% trans "Ticket" %}</a>
  52 + </li>
  53 + <li>
  54 + <span class="glyphicon glyphicon-align-right"></span>
  55 + <a href="{% append_to_get type='changeset' %}">{% trans "Changeset" %}</a>
  56 + </li>
  57 + <li>
  58 + <span class="glyphicon glyphicon-user"></span>
  59 + <a href="{% append_to_get type='user' %}">{% trans "User" %}</a>
  60 + </li>
  61 + </ul>
  62 + </div>
  63 +
  64 + <div class="col-lg-10">
  65 + <ul class="list-unstyled">
  66 + {% for result in page.object_list %}
  67 + {% include "search/preview-search.html" %}
  68 + {% empty %}
  69 + <li class="text-center">
  70 + {% trans "No results for your search." %}
  71 + </li>
  72 + {% endfor %}
  73 + </ul>
  74 +
  75 + {% if query and page.has_other_pages %}
  76 + <div class="text-center">
  77 + <span>
  78 + {% if page.has_previous %}
  79 + <a href="{% append_to_get page=page.previous_page_number %}">{% trans "Previous" %}</a>
  80 + {% endif %}
  81 + <span>
  82 + {% trans "Page" %} {{ page.number }} {% trans "of" %}
  83 + {{ page.paginator.num_pages }}
  84 + </span>
  85 +
  86 + {% if page.has_next %}
  87 + <a href="{% append_to_get page=page.next_page_number %}">{% trans "Next" %}</a>
  88 + {% endif %}
  89 + </span>
  90 + </div>
  91 + {% endif %}
  92 +
  93 + </div>
  94 + </div>
  95 +
  96 +{% endblock %}