← Back to team overview

harvest-dev team mailing list archive

[Merge] lp:~dylanmccall/harvest/harvest-dylan-m into lp:harvest

 

Dylan McCall has proposed merging lp:~dylanmccall/harvest/harvest-dylan-m into lp:harvest.

Requested reviews:
  harvest-dev (harvest-dev)


Introducing a new view at /opportunities/filter, which aims to replace all existing views in the future.

This view presents all packages and all opportunities tracked by Harvest, where the user can filter them using a list of properties. First the list of source packages is filtered, then the list of opportunities related to those packages is filtered.

User interface is just a proof of concept so far, with very few avenues for interaction (beyond toggling some filters in a rudimentary way). The back-end lets us quickly define new properties to filter packages and opportunities by, and these are instantly reflected in the output.

Everything happens through static pages at the moment, with parameters passed in the query string. Any interaction that involves clicking a link, including expanding a package to show its related opportunities, is a full page load away.
-- 
https://code.launchpad.net/~dylanmccall/harvest/harvest-dylan-m/+merge/28262
Your team harvest-dev is requested to review the proposed merge of lp:~dylanmccall/harvest/harvest-dylan-m into lp:harvest.
=== modified file 'INSTALL'
--- INSTALL	2010-03-02 10:42:19 +0000
+++ INSTALL	2010-06-23 03:56:24 +0000
@@ -1,4 +1,4 @@
-1. sudo apt-get install python-django python-launchpadlib python-django-openid-auth bzr
+1. sudo apt-get install python-django python-launchpadlib python-django-openid-auth bzr python-django-debug-toolbar
 
 ---
 Optional for postgres usage:

=== added file 'harvest/common/url_tools.py'
--- harvest/common/url_tools.py	1970-01-01 00:00:00 +0000
+++ harvest/common/url_tools.py	2010-06-23 03:56:24 +0000
@@ -0,0 +1,31 @@
+def new_url_with_parameters(url, params_dict):
+    """
+    Returns a new URL with an added query string, described by params_dict.
+    @param params_dict: a dictionary with all the parameters to add
+    @param path: url to add the parameters to
+    @return: the url (a string) with given parameters 
+    """
+    #Derived from <http://djangosnippets.org/snippets/1627/>
+    
+    def param_bit(key, value):
+        if value:
+            return "%s=%s" % (key, value)
+        else:
+            return "%s" % key
+    
+    if len(params_dict):
+        url_params = list()
+        url += "?%s" % "&".join([param_bit(key, value) for (key, value) in params_dict.items()])
+    
+    return url
+
+def current_url_with_parameters(request, new_params_dict):
+    """
+    Returns the current URL with some parameters changed, which are
+    described in new_params_dict. The rest of the query string remains
+    intact.
+    """
+    params = request.GET.copy() #this includes parameters that aren't used by the FilterSystem
+    params.update(new_params_dict)
+    url = request.path
+    return new_url_with_parameters(url, params)
\ No newline at end of file

=== added directory 'harvest/filters'
=== added file 'harvest/filters/__init__.py'
--- harvest/filters/__init__.py	1970-01-01 00:00:00 +0000
+++ harvest/filters/__init__.py	2010-06-23 03:56:24 +0000
@@ -0,0 +1,23 @@
+"""
+Here, we define a very abstract filtering system.
+This system is entirely decoupled from presentation. A filter is simply a query
+operation intended for a particular type of model, which can be configured and
+turned on / off via structured input.
+
+There are, of course, some rules: different types of filters can be added,
+some of which store extra information (like a line of text that the user can
+input), and filters can be grouped like radio buttons where only one filter
+in a group can be selected.
+
+The included classes provide most important features for free, but to be of
+any use they need to be extended to fit the intended application. For example,
+the get_query class is unimplemented; it should return a Q object that does
+the work of selecting whatever the filter is intended to select.
+
+Groups of filters in no way refer to how they get placed in a page, or how
+they are interacted with. We leave that entirely up to templates. It is also
+something else's job to figure out how to draw filters, and how to form the
+links they talk to us with.
+
+All This Does is filter things.
+"""

=== added file 'harvest/filters/containers.py'
--- harvest/filters/containers.py	1970-01-01 00:00:00 +0000
+++ harvest/filters/containers.py	2010-06-23 03:56:24 +0000
@@ -0,0 +1,100 @@
+from harvest.common.url_tools import current_url_with_parameters
+
+class FilterContainer(object): #abstract
+    """
+    A class that contains Filter objects, which can be retrieved with
+    the get(full_name) method. Actually adding filters needs to be
+    implemented elsewhere.
+    """
+    
+    def __init__(self, filters_set): 
+        self.set_filters(filters_set)
+    
+    def set_filters(self, filters_set): #final
+        """
+        Add a set of filters to be children of this one.
+        @param filter_set: a set of Filter objects
+        """
+        self.filters_dict = dict() #refers to Filter objects by their unique IDs
+        for child in set(filters_set):
+            self.filters_dict[child.get_id()] = child
+            child.set_container(self)
+    
+    def get(self, full_name): #final
+        """
+        Finds a filter inside this object or one of its children, based
+        on that filter's full name.
+        @param full_name: an object's full name in the format container:child
+        @return: the object described by full_name, or None if it is not found
+        """
+        result = None
+        name_bits = full_name.split(':',1)
+        
+        #the first bit is the one this instance stores
+        if name_bits[0] in self.filters_dict:
+            result = self.filters_dict[name_bits[0]]
+        else:
+            result = None
+        
+        if isinstance(result, FilterContainer) and len(name_bits)>1:
+            return result.get(name_bits[1]) #send remaining bits to child
+        else:
+            #we have reached the end of the list. Return the result (which may be None)
+            return result
+
+
+
+class FilterSystem(FilterContainer): #abstract, extend in application
+    """
+    Extend this object to create the root of all filters belonging to
+    an application.
+    
+    This object contains helper functions to deal with http requests
+    and for finding filters based on their full names.
+    """
+    
+    def __init__(self, filters_set, default_parameters = dict()): #final
+        FilterContainer.__init__(self, filters_set)
+        self.request = None #current http request
+        self.default_parameters = default_parameters
+        self.parameters = dict() #holds current parameters for the whole system
+    
+    def update_http(self, request): #final
+        """
+        Updates the state of the system based on the HTTP request.
+        This function must be called before anything else can happen.
+        @param request: New HttpRequest object
+        """
+        self.request = request
+        #note that this currently stores parameters that aren't relevant to the FilterSystem
+        #we need to do that so we don't eat them in get_url_with_parameters
+        self.set_parameters(request.GET)
+    
+    def set_parameters(self, parameters): #final
+        """
+        Updates the state of all filters based on given parameters.
+        @param parameters: dictionary of parameters, for example from request.GET
+        """
+        
+        new_params = parameters.copy()
+        for key in self.default_parameters:
+            if not key in new_params: new_params[key] = self.default_parameters[key]
+        
+        #store the parameters for later use
+        self.parameters = new_params
+        
+        for key in new_params:
+            filter_object = self.get(key)
+            if filter_object:
+                filter_object.set_value(new_params[key])
+    
+    def get_parameters(self): #final
+        """
+        Returns a dictionary of current parameters for the entire
+        system. This can be modified and used in get_url_with_parameters.
+        @return: a dictionary of parameters
+        """
+        return self.parameters.copy()
+    
+    def get_url_with_parameters(self, parameters): #final
+        return current_url_with_parameters(self.request, parameters)

=== added file 'harvest/filters/filters.py'
--- harvest/filters/filters.py	1970-01-01 00:00:00 +0000
+++ harvest/filters/filters.py	2010-06-23 03:56:24 +0000
@@ -0,0 +1,267 @@
+#FIXME: Make ChoiceFilter a bit nicer so we don't need to call super() and all that bother from harvest.opportunities.filters.
+#TODO: check over strings that should be unicode, make sure they are, mention in docstrings
+#TODO: adjust Filter.render() functions for custom template tags, with django.template.Template
+
+from containers import FilterContainer, FilterSystem
+from django.utils.safestring import mark_safe
+from copy import copy
+
+class Filter(object): #abstract, extend in application
+    def __init__(self, id_str): #final
+        self.id_str = id_str
+        self.container = None #immediate container (FilterContainer)
+    
+    def get_id(self): #final
+        return self.id_str
+    
+    def set_container(self, container): #final
+        self.container = container
+    
+    def get_container(self): #final
+        return self.container
+    
+    def get_system(self): #final
+        container = self.get_container()
+        system = None
+        if isinstance(container, Filter): 
+            system = container.get_system()
+        elif isinstance(container, FilterSystem):
+            system = container
+        return system
+    
+    def get_full_name(self): #final
+        """
+        Returns a unique name for the filter that makes sense globally.
+        """
+        full_name = self.get_id()
+        container = self.get_container()
+        if isinstance(container, Filter):
+            full_name = "%s:%s" % (container.get_full_name(), full_name)
+        return full_name
+    
+    def set_value(self, value): #abstract
+        """
+        Extend this to take a value passed down from the top, probably
+        from FilterSystem.update_http, and do something with it.
+        @param value: the value received
+        @type value: a unicode string
+        """
+        pass
+    
+    def get_value(self): #abstract
+        """
+        @return: a copy of this instance's value, in a format native to its type 
+        """
+        pass
+    
+    def get_value_string(self, value = None): #final
+        """
+        @param choices_dict: Optional value to be used instead of internal value
+        @return: a unicode string formatted for a URL's GET parameters
+        """
+        if not value: value = self.get_value()
+        return get_value_string_for(self, value) #this does feel a tad wasteful
+    
+    def get_value_string_for(self, value): #abstract
+        """
+        Extend this to return the value of this filter in the same
+        format it comes in. Used to generate URLs.
+        @param choices_dict: a different value, in the same format as from get_value(), to use instead of the internal one
+        @return: a unicode string formatted for a URL's GET parameters
+        """
+        pass
+    
+    def get_container_toggle_parameters(self): #final
+        """
+        Helper function to get the parameter for toggling this filter's
+        state in its container, if there is one.
+        @return: a dictionary of key:value pairs to generate new GET parameters
+        """
+        container = self.get_container()
+        params = dict()
+        if isinstance(container, FilterGroup): 
+            params = container.get_parameters_to_toggle(self.get_id())
+        return params
+    
+    def get_parameters_for_value(self, value): #final
+        """
+        Returns a dictionary of parameters to send the given value to
+        this object in the future. To be merged with global set from
+        FilterSystem.get_parameters().
+        @param value: the value in a format native to this Filter. (See get_value)
+        @return: a dictionary of key:value pairs to generate new GET parameters
+        """
+        key = self.get_full_name()
+        value_str = self.get_value_string_for(value)
+        return {key : value_str}
+    
+    def process_queryset(self, queryset): #abstract
+        """
+        Extend this to manipulate a given queryset and then return it.
+        For example, queryset.filter(name__startswith = self.value)
+        @param queryset: a queryset to operate on
+        @return: a queryset based on the given one
+        """
+        return queryset.all()
+    
+    def render(self): #final
+        """
+        @return: the default rendering of the filter itself in given context
+        """
+        return self.render_html()
+    
+    def render_html(self): #abstract
+        """
+        Extend this to return the html output for the filter itself.
+        The output should be very simple and semantically meaningful,
+        with no specific concern about formatting. It will be
+        placed within other  tags that describe its context, and it is
+        up to the template to describe style.
+        @return: a unicode string containing html representing this filter
+        """
+        system = self.get_system()
+        toggle_params = self.get_container_toggle_parameters() 
+        href = system.get_url_with_parameters(toggle_params)
+        
+        return mark_safe(u'<a href="%s">(%s)</a>'
+            % (href, self.get_id()))
+
+
+
+
+class EditFilter(Filter): #abstract, extend in application
+    def __init__(self, id_str):
+        Filter.__init__(self, id_str)
+        self.input_str = ""
+    
+    def set_value(self, value): #overrides Filter
+        self.input_str = value
+    
+    def get_value(self): #overrides Filter
+        return self.input_str
+    
+    def get_value_string_for(self, value): #overrides Filter
+        return value
+    
+    def render_html(self):
+        system = self.get_system()
+        toggle_params = self.get_container_toggle_parameters() 
+        href = system.get_url_with_parameters(toggle_params)
+        
+        return mark_safe(u'<a href="%s">%s: %s</a>'
+            % (href, self.get_id(), self.get_value()))
+
+
+class ListFilter(Filter): #abstract, extend in application
+    def __init__(self, id_str):
+        Filter.__init__(self, id_str)
+        self.selected_set = set()
+    
+    def set_value(self, value): #overrides Filter
+        """
+        @param value: a string containing a comma separated list of values
+        """
+        self.selected_set = set([s for s in value.split(",") if self.id_allowed(s)])
+    
+    def get_value(self): #overrides Filter
+        return self.selected_set.copy()
+    
+    def get_value_string_for(self, value): #overrides Filter
+        return ",".join(value)
+    
+    def id_selected(self, item_id):
+        return item_id in self.selected_set
+    
+    def id_allowed(self, item_id): #abstract
+        return True
+    
+    def get_parameters_to_toggle(self, item_id): #final
+        """
+        Returns parameters to toggle (add or remove) the given item
+        as a selection of this ListFilter.
+        """
+        select = self.selected_set.copy()
+        if item_id in select:
+            select.remove(item_id)
+        else:
+            select.add(item_id)
+        
+        return self.get_parameters_for_value(select)
+        
+
+class ChoiceFilter(ListFilter): #abstract, extend in application
+    def __init__(self, id_str, choices_dict):
+        ListFilter.__init__(self, id_str)
+        self.choices_dict = choices_dict
+    
+    def id_allowed(self, item_id): #overrides ListFilter
+        return item_id in self.choices_dict
+    
+    def get_selected_items(self):
+        return [self.choices_dict[s] for s in self.selected_set]
+        
+    def render_html(self): #overrides Filter
+        choices = ""
+        
+        for c in self.choices_dict:
+            c_render = self.render_html_choice(c)
+            if self.id_selected(c):
+                c_render = "<b>%s</b>" % c_render
+            choices += "<li>%s</li>" % c_render
+        
+        system = self.get_system()
+        toggle_params = self.get_container_toggle_parameters() 
+        self_href = system.get_url_with_parameters(toggle_params)
+        
+        return mark_safe(u'<a href="%s">%s</a>:<ul>%s</ul>' % (self_href, self.get_id(), choices))
+    
+    def render_html_choice(self, item_id): #abstract
+        system = self.get_system()
+        toggle_params = self.get_parameters_to_toggle(item_id)
+        item_href = system.get_url_with_parameters(toggle_params)
+        
+        return mark_safe(u'<a href="%s">%s</a>' % (item_href, item_id))
+
+
+
+
+class FilterGroup(FilterContainer, ListFilter): #final
+    """
+    A collection of other filters. Children are enabled and disabled
+    freely. Their do_queryset functions are all mixed together into one.
+    """
+    
+    def __init__(self, id_str, filters_set):
+        FilterContainer.__init__(self, filters_set)
+        ListFilter.__init__(self, id_str) 
+    
+    def id_allowed(self, item_id): #overrides ListFilter
+        return item_id in self.filters_dict
+    
+    def get_selected_filters(self, filter_id):
+        return [self.filters_dict[s] for s in self.selected_set]
+    
+    def process_queryset(self, queryset): #overrides Filter
+        """
+        Manipulates a queryset using the currently selected filters.
+        @param queryset:  the queryset to be filtered
+        @return: a new queryset, filtered based on selected filters
+        """
+        for f in self.selected_set:
+            queryset = self.filters_dict[f].process_queryset(queryset) #returns something like QuerySet.filter(blah)
+        return queryset
+    
+    def render_html(self): #overrides Filter
+        filters = ""
+        
+        for f in self.filters_dict:
+            f_render = self.render_html_filter(f)
+            if self.id_selected(f):
+                f_render = "<em>%s</em>" % f_render
+            filters += "<li>%s</li>" % f_render
+        
+        return mark_safe(u'%s:<ul>%s</ul>' % (self.get_id(), filters))
+    
+    def render_html_filter(self, filter_id):
+        f = self.filters_dict[filter_id]
+        return f.render_html()
\ No newline at end of file

=== added file 'harvest/opportunities/filters.py'
--- harvest/opportunities/filters.py	1970-01-01 00:00:00 +0000
+++ harvest/opportunities/filters.py	2010-06-23 03:56:24 +0000
@@ -0,0 +1,52 @@
+from harvest.filters import filters, containers
+import models
+
+class PkgNameFilter(filters.EditFilter):
+    def process_queryset(self, queryset):
+        return queryset.filter(name__startswith = self.get_value())
+
+class PkgSetFilter(filters.ChoiceFilter):
+    def __init__(self, id_str):
+        choices_dict = dict()
+        for s in models.PackageSet.objects.all():
+            choices_dict[s.name] = s
+        super(PkgSetFilter, self).__init__(id_str, choices_dict)
+    
+    def process_queryset(self, queryset):
+        return queryset.filter(packagesets__in=self.get_selected_items())
+
+
+
+class OppFeaturedFilter(filters.Filter):
+    def process_queryset(self, queryset):
+        return queryset.filter(opportunitylist__featured=True)
+
+class OppListFilter(filters.ChoiceFilter):
+    def __init__(self, id_str):
+        choices_dict = dict()
+        for l in models.OpportunityList.objects.all():
+            choices_dict[l.name] = l
+        super(OppListFilter, self).__init__(id_str, choices_dict)
+    
+    def process_queryset(self, queryset):
+        return queryset.filter(opportunitylist__in=self.get_selected_items())
+
+
+
+class HarvestFilters(containers.FilterSystem):
+    def __init__(self):
+        super(HarvestFilters, self).__init__(
+            [
+                filters.FilterGroup("pkg", [
+                    PkgNameFilter("name"),
+                    PkgSetFilter("set")
+                ] ),
+                filters.FilterGroup("opp", [
+                    OppFeaturedFilter("featured"),
+                    OppListFilter("list")
+                ] )
+            ],
+            default_parameters = { "pkg" : "name,set",
+                                   "pkg:name" : "ged",
+                                   "pkg:set" : "ubuntu-desktop" }
+        )

=== modified file 'harvest/opportunities/urls.py'
--- harvest/opportunities/urls.py	2010-03-08 16:33:21 +0000
+++ harvest/opportunities/urls.py	2010-06-23 03:56:24 +0000
@@ -12,6 +12,10 @@
 
     url(r'^source-package/(?P<sourcepackage_slug>[-\w+.]+)/$', 'opportunities.views.sourcepackage_detail', name='sourcepackage_detail'),
     url(r'^source-package/$', 'opportunities.views.sourcepackage_list', name='sourcepackage_list'),
+    
+    url(r'^filter',
+        'opportunities.views.opportunities_filter',
+        name='opportunities_filter'),
 
     url(r'^by-type',
         'opportunities.views.opportunities_by_type',

=== modified file 'harvest/opportunities/views.py'
--- harvest/opportunities/views.py	2010-06-08 15:46:42 +0000
+++ harvest/opportunities/views.py	2010-06-23 03:56:24 +0000
@@ -13,6 +13,9 @@
 import models
 import forms
 
+from filters import HarvestFilters
+from wrappers import PackageWrapper, PackageListWrapper
+
 def opportunity_index(request):
     sources_list = models.SourcePackage.objects.all()
     paginator = Paginator(sources_list, 50)
@@ -120,6 +123,37 @@
         extra_context = {'opportunities': opportunities},
     )
 
+def opportunities_filter(request):
+    filters = HarvestFilters()
+    filters.update_http(request)
+    filters_pkg = filters.get('pkg')
+    filters_opp = filters.get('opp')
+    
+    packages_list = models.SourcePackage.objects.distinct()
+    packages_list = filters_pkg.process_queryset(packages_list)
+    
+    #opportunities_list is filtered right away to only check opportunities belonging to selected packages
+    opportunities_list = models.Opportunity.objects.distinct().filter(sourcepackage__in=packages_list)
+    opportunities_list = filters_opp.process_queryset(opportunities_list)
+    #TODO: need to filter out opportunities with valid=False again
+    #TODO: would it be more efficient to group opportunities by their sourcepackages first, then run filters_opp.process_queryset() for each of those groups?
+    
+    pkg_list_wrapper = PackageListWrapper(request, packages_list, opportunities_list) 
+    
+    context = {
+        'grouping': 'package',
+        'packages_list': pkg_list_wrapper,
+        'filters_pkg' : filters_pkg,
+        'filters_opp' : filters_opp
+    }
+
+    return render(
+        'opportunities/opportunities_filter.html',
+        context,
+        context_instance=RequestContext(request))
+
+#TODO: package_filter_detail(request, sourcepackage, opportunities_list)
+
 def opportunities_by_type(request):
     types_list = models.OpportunityList.objects.filter(active=True)
     paginator = Paginator(types_list, 50)

=== added file 'harvest/opportunities/wrappers.py'
--- harvest/opportunities/wrappers.py	1970-01-01 00:00:00 +0000
+++ harvest/opportunities/wrappers.py	2010-06-23 03:56:24 +0000
@@ -0,0 +1,90 @@
+from django.db.models import Count
+from harvest.common.url_tools import current_url_with_parameters
+
+class PackageWrapper(object):
+    """
+    Describes a visible source package, for specific use in a
+    template.
+    """
+    
+    def __init__(self, request, package, visible_opportunities = None, expanded = False):
+        self.request = request
+        self.package = package
+        self.visible_opportunities = visible_opportunities
+        self.expanded = expanded
+    
+    def real(self):
+        return self.package
+    
+    def get_expand_toggle_url(self):
+        parameter = {'expand_pkg' : self.package.name}
+        url = current_url_with_parameters(self.request, parameter)
+        return url
+    
+    #FIXME: get_visible_opportunities and get_hidden_opportunities feel
+    #       wasteful. Could we do exclude and filter in a single
+    #       operation? Does it affect performance?
+    def get_visible_opportunities(self):
+        """
+        Returns opportunities that belong to the given package and are
+        in opportunities_list.
+        """
+        #also check if valid?
+        return self.visible_opportunities
+    
+    def get_hidden_opportunities(self):
+        """
+        Returns opportunities that belong to the given package but have
+        been hidden from view
+        """
+        opps_visible = self.get_visible_opportunities()
+        return self.package.opportunity_set.exclude(pk__in=opps_visible)
+
+class PackageListWrapper(object):
+    """
+    Object describing a list of source packages and opportunities, to
+    be used by a template. It contains UI-specific variables and simple
+    helper functions for doing final queries to access these lists.
+    """
+    
+    def __init__(self, request, packages_list, opportunities_list):
+        expand_list = None #list of packages to show in detail
+        if 'expand_pkg' in request.GET:
+            expand_list = request.GET['expand_pkg'].split(',')
+        
+        related_packages = set(opportunities_list.values_list('sourcepackage', flat=True))
+        
+        self.visible_packages_list = list()
+        self.hidden_packages_list = list() 
+        
+        #Create a PackageWrapper around every source package.
+        #Includes a less detailed wrapper for hidden packages.
+        for package in packages_list:
+            if package.pk in related_packages:
+                opps = None
+                expand = False
+                
+                if expand_list: expand = (package.name in expand_list)
+                opps = opportunities_list.filter(sourcepackage=package)
+                
+                package_wrapper = PackageWrapper(request, package,
+                                                 visible_opportunities = opps,
+                                                 expanded = expand)
+                self.visible_packages_list.append(package_wrapper)
+            
+            else:
+                package_wrapper = PackageWrapper(request, package)
+                self.hidden_packages_list.append(package_wrapper)
+    
+    def get_visible_packages(self):
+        """
+        Returns list of packages that are are visible.
+        These are any packages that contain opportunities.
+        """
+        return self.visible_packages_list
+    
+    def get_hidden_packages(self):
+        """
+        Returns list of packages that have been hidden from view.
+        """
+        return self.hidden_packages_list
\ No newline at end of file

=== modified file 'harvest/settings.py'
--- harvest/settings.py	2010-06-01 16:16:19 +0000
+++ harvest/settings.py	2010-06-23 03:56:24 +0000
@@ -7,6 +7,7 @@
 TEMPLATE_DEBUG = DEBUG
 STATIC_SERVE = True
 PROJECT_NAME = 'harvest'
+INTERNAL_IPS = ('127.0.0.1',) #for testing
 
 from common import utils
 VERSION_STRING = utils.get_harvest_version(
@@ -70,6 +71,7 @@
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.middleware.locale.LocaleMiddleware',
+    'debug_toolbar.middleware.DebugToolbarMiddleware', #for testing
 )
 
 ROOT_URLCONF = 'harvest.urls'
@@ -98,7 +100,9 @@
     'django.contrib.sites',
     'django.contrib.admin',
     'django_openid_auth',
+    'debug_toolbar', #for testing
     'opportunities',
+    'filters',
     'common',
 )
 

=== added file 'harvest/templates/opportunities/opportunities_filter.html'
--- harvest/templates/opportunities/opportunities_filter.html	1970-01-01 00:00:00 +0000
+++ harvest/templates/opportunities/opportunities_filter.html	2010-06-23 03:56:24 +0000
@@ -0,0 +1,50 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block title %}{% trans "Opportunity Index" %} - {{ block.super }}{% endblock %}
+
+{% block content %}
+<div class="mainpage">
+
+<h1>{% trans "Opportunities" %}</h1>
+
+<div class="filters" style="background-color:#E0F1FF; float:left; width:15em;">
+	{{filters_pkg.render}}
+	{{filters_opp.render}}
+</div>
+
+<div class="results" style="float:left;">
+{% if packages_list %}
+<ul>
+	{% for pkg in packages_list.get_visible_packages %}
+	<li><a href="{{ pkg.get_expand_toggle_url }}">{{ pkg.real.name }}</a>
+		{% if pkg.expanded %}
+		<ul>
+		{% for opportunity in pkg.get_visible_opportunities %}
+			{% include "opportunities/opportunity_detail.inc.html" %}
+		{% endfor %}
+		
+		{% with pkg.get_hidden_opportunities.count as hidden_count %}
+		{% ifnotequal hidden_count 0 %}
+		<li><small>{{ hidden_count }} {{ hidden_count|pluralize:"opportunity,opportunities"}} hidden</small></li>
+		{% endifnotequal %}
+		{% endwith %}
+		</ul>
+		{% endif %}
+	</li>
+	{% endfor %}
+	
+	{% with packages_list.get_hidden_packages|length as hidden_count %}
+	{% ifnotequal hidden_count 0 %}
+	<li><small>{{ hidden_count }} package{{ hidden_count|pluralize:"s"}} {{ hidden_count|pluralize:"has,have"}} no matching opportunities</small></li>
+	{% endifnotequal %}
+	{% endwith %}
+</ul>
+
+{% else %}
+<p>{% trans "There are currently no opportunities in Harvest. :(" %}</p>
+{% endif %}
+</div>
+
+</div>
+{% endblock %}


Follow ups