class ProjectSuggestForm(forms.ModelForm): """form for project suggested by user.""" CATEGORIES_QS = Category.objects \ .select_related('theme') \ .order_by('theme__name', 'name') categories = CategoryMultipleChoiceField( group_by_theme=True, label=_('Themes'), queryset=CATEGORIES_QS, to_field_name='slug', required=False,) name = forms.CharField( label=_('Name of your project'), help_text=_('Build a media library, build a bicycle road, ...')) description = forms.CharField( label=_('Describe your project in a few words'), widget=forms.Textarea, required=False, help_text=_('Its goal, its mobilization step or any informations' ' that can identify your project')) class Meta: model = Project fields = ['name', 'description', 'categories']
class AidAdminForm(BaseAidForm): """Custom Aid edition admin form.""" financer_suggestion = forms.CharField( label=_('Financer suggestion'), max_length=256, required=False, help_text=_('This financer was suggested. Add it to the global list ' 'then add it to this aid with the field above.')) instructor_suggestion = forms.CharField( label=_('Instructor suggestion'), max_length=256, required=False, help_text=_('This instructor was suggested. Add it to the global list ' 'then add it to this aid with the field above.')) categories = CategoryMultipleChoiceField(label=_('Categories'), required=False, widget=FilteredSelectMultiple( _('Categories'), True)) class Meta: widgets = { 'name': forms.Textarea(attrs={'rows': 3}), 'mobilization_steps': forms.CheckboxSelectMultiple, 'targeted_audiances': forms.CheckboxSelectMultiple, 'aid_types': forms.CheckboxSelectMultiple, 'destinations': forms.CheckboxSelectMultiple, } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['financers'].required = False self.fields['tags'].widget.attrs['class'] = 'admin-autocomplete' self.fields['start_date'].required = False
class ProjectForm(forms.ModelForm): description = RichTextField(label=_('Description'), required=False) categories = CategoryMultipleChoiceField(label=_('Categories'), required=False, widget=FilteredSelectMultiple( _('Categories'), True)) class Meta: model = Project fields = '__all__'
class SearchPageAdminForm(forms.ModelForm): content = RichTextField(label=_('Page content'), help_text=_('Full description of the page. ' 'Will be displayed above results.')) more_content = RichTextField( label=_('More content'), help_text=_('Hidden content, only revealed on a `See more` click.')) available_categories = CategoryMultipleChoiceField( label=_('Categories'), required=False, widget=FilteredSelectMultiple(_('Categories'), True))
class SearchPageAdminForm(forms.ModelForm): content = RichTextField( label='Contenu de la page', help_text='Description complète de la page. Sera affichée au dessus des résultats.') more_content = RichTextField( label='Contenu additionnel', help_text='Contenu caché, révélé au clic sur le bouton « Voir plus ».') available_categories = CategoryMultipleChoiceField( label='Sous-thématiques', required=False, widget=FilteredSelectMultiple('Sous-thématiques', True)) def clean(self): """Validate search page customization consistency. Filters consistency: we need to make sure that at least one search form field is selected. """ data = super().clean() search_fields = [ 'show_perimeter_field', 'show_audience_field', 'show_categories_field', 'show_mobilization_step_field', 'show_aid_type_field', 'show_backers_field', 'show_text_field', ] # If there is no form customization fields, then we don't # need to run the fields validation. That's tipically the case # for lite admin page available to contributors that don't have # access to form customization. all_form_fields = self.fields.keys() has_form_customization_fields = any( field in all_form_fields for field in search_fields) if not has_form_customization_fields: return data nb_filters = 0 for field in search_fields: if field in data and data[field]: nb_filters += 1 if nb_filters == 0: raise ValidationError( 'Vous devez sélectionner au moins un filtre pour le formulaire de recherche.', code='not_enough_filters') if nb_filters > 3: raise ValidationError( 'Vous ne devez pas sélectionner plus de trois filtres pour le formulaire de recherche.', # noqa code='too_many_filters') return data
class SearchPageAdminForm(forms.ModelForm): content = RichTextField( label=_('Page content'), help_text=_('Full description of the page. ' 'Will be displayed above results.')) more_content = RichTextField( label=_('More content'), help_text=_('Hidden content, only revealed on a `See more` click.')) available_categories = CategoryMultipleChoiceField( label=_('Categories'), required=False, widget=FilteredSelectMultiple(_('Categories'), True)) def clean(self): """Validate search page customization consistency. Filters consistency: we need to make sure that at least one search form field is selected. """ data = super().clean() search_fields = [ 'show_perimeter_field', 'show_audience_field', 'show_categories_field', 'show_mobilization_step_field', 'show_aid_type_field', ] nb_filters = 0 for field in search_fields: if field in data and data[field]: nb_filters += 1 if nb_filters == 0: raise ValidationError( _('You need to select at least one search form filter.'), code='not_enough_filters') if nb_filters > 3: raise ValidationError( _('You need to select less than four search form filters.'), code='too_many_filters') return data
class AidAdminForm(BaseAidForm): """Custom Aid edition admin form.""" perimeter_suggestion = forms.CharField( label='Périmètre suggéré', max_length=256, required=False, help_text='Le contributeur suggère ce nouveau périmètre') financer_suggestion = forms.CharField( label="Porteurs suggérés", max_length=256, required=False, help_text= "Ce porteur a été suggéré. Créez le nouveau porteur et ajouter le en tant que porteur d'aides via le champ approprié." ) # noqa instructor_suggestion = forms.CharField( label='Instructeurs suggérés', max_length=256, required=False, help_text= "Cet instructeur a été suggéré. Créez le nouveau porteur et ajouter le en tant qu'instructeur via le champ approprié." ) # noqa categories = CategoryMultipleChoiceField(label='Sous-thématiques', required=False, widget=FilteredSelectMultiple( 'Sous-thématiques', True)) class Meta: widgets = { 'name': forms.Textarea(attrs={'rows': 3}), 'mobilization_steps': forms.CheckboxSelectMultiple, 'targeted_audiences': forms.CheckboxSelectMultiple, 'aid_types': forms.CheckboxSelectMultiple, 'destinations': forms.CheckboxSelectMultiple, } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if 'start_date' in self.fields: self.fields['start_date'].required = False
class BaseAidSearchForm(forms.Form): """Main form for search engine.""" AID_CATEGORY_CHOICES = ( ('', ''), ('funding', 'Financière'), ('non-funding', 'Non-financière'), ) ORDER_BY = ( ('relevance', 'Tri : pertinence'), ('publication_date', 'Tri : date de publication'), ('submission_deadline', 'Tri : date de clôture'), ) CATEGORIES_QS = Category.objects \ .select_related('theme') \ .order_by('theme__name', 'name') text = forms.CharField( label='Recherche textuelle', required=False, widget=forms.TextInput( attrs={'placeholder': 'Titre, sujet, mot-clé, etc.'})) apply_before = forms.DateField( label='Candidater avant…', required=False, widget=forms.TextInput(attrs={'type': 'date'})) published_after = forms.DateTimeField( label='Publiée après…', required=False, widget=forms.TextInput(attrs={'type': 'date'})) aid_type = forms.MultipleChoiceField(label="Nature de l'aide", choices=AID_TYPE_CHOICES, required=False, widget=forms.CheckboxSelectMultiple) financial_aids = forms.MultipleChoiceField( label='Aides financières', required=False, choices=FINANCIAL_AIDS, widget=forms.CheckboxSelectMultiple) technical_aids = forms.MultipleChoiceField( label='Aides en ingénierie', required=False, choices=TECHNICAL_AIDS, widget=forms.CheckboxSelectMultiple) mobilization_step = forms.MultipleChoiceField( label='Avancement du projet', required=False, choices=Aid.STEPS, widget=forms.CheckboxSelectMultiple) destinations = forms.MultipleChoiceField( label='Actions concernées', required=False, choices=Aid.DESTINATIONS, widget=forms.CheckboxSelectMultiple) recurrence = forms.ChoiceField(label='Récurrence', required=False, choices=Aid.RECURRENCES) call_for_projects_only = forms.BooleanField( label="Appels à projets / Appels à manifestation d'intérêt uniquement", required=False) backers = AutocompleteModelMultipleChoiceField( label="Porteurs d'aides", queryset=Backer.objects.all(), required=False) programs = forms.ModelMultipleChoiceField(label="Programmes d'aides", queryset=Program.objects.all(), to_field_name='slug', required=False) in_france_relance = forms.BooleanField(label='Aides France Relance :', required=False) themes = forms.ModelMultipleChoiceField(label='Thématiques', queryset=Theme.objects.all(), to_field_name='slug', required=False, widget=forms.MultipleHiddenInput) categories = CategoryMultipleChoiceField( group_by_theme=True, label='Thématiques', # Not a mistake queryset=CATEGORIES_QS, to_field_name='slug', required=False) targeted_audiences = forms.MultipleChoiceField( label='La structure pour laquelle vous recherchez des aides est…', required=False, choices=Aid.AUDIENCES, widget=forms.CheckboxSelectMultiple) perimeter = AutocompleteModelChoiceField(queryset=Perimeter.objects.all(), label='Votre territoire', required=False) origin_url = forms.URLField(label="URL d'origine", required=False) # This field is not related to the search, but is submitted # in views embedded through an iframe. integration = forms.CharField(required=False, widget=forms.HiddenInput) # This field is used to sort results order_by = forms.ChoiceField(label='Trier par', required=False, choices=ORDER_BY) def clean_zipcode(self): zipcode = self.cleaned_data['zipcode'] if zipcode and re.match(r'\d{5}', zipcode) is None: msg = 'Ce code postal semble invalide.' raise forms.ValidationError(msg) return zipcode def filter_queryset(self, qs=None, apply_generic_aid_filter=True): """Filter querysets depending of input data.""" # If no qs was passed, just start with all published aids if qs is None: qs = Aid.objects.published().open() if not self.is_bound: return qs # Populate cleaned_data if not hasattr(self, 'cleaned_data'): self.full_clean() perimeter = self.cleaned_data.get('perimeter', None) if perimeter: qs = self.perimeter_filter(qs, perimeter) mobilization_steps = self.cleaned_data.get('mobilization_step', None) if mobilization_steps: qs = qs.filter(mobilization_steps__overlap=mobilization_steps) # Those two form fields are split for form readability, # but they relate to a single model field. financial_aids = self.cleaned_data.get('financial_aids', []) technical_aids = self.cleaned_data.get('technical_aids', []) aid_types = financial_aids + technical_aids aid_type = self.cleaned_data.get('aid_type', []) if 'financial' in aid_type: aid_types += FINANCIAL_AIDS_LIST if 'technical' in aid_type: aid_types += TECHNICAL_AIDS_LIST if aid_types: qs = qs.filter(aid_types__overlap=aid_types) destinations = self.cleaned_data.get('destinations', None) if destinations: qs = qs.filter(destinations__overlap=destinations) apply_before = self.cleaned_data.get('apply_before', None) if apply_before: qs = qs.filter(submission_deadline__lte=apply_before) published_after = self.cleaned_data.get('published_after', None) if published_after: qs = qs.filter(date_published__gte=published_after) recurrence = self.cleaned_data.get('recurrence', None) if recurrence: qs = qs.filter(recurrence=recurrence) call_for_projects_only = self.cleaned_data.get( 'call_for_projects_only', False) if call_for_projects_only: qs = qs.filter(is_call_for_project=True) in_france_relance = self.cleaned_data.get('in_france_relance', False) if in_france_relance: qs = qs.filter(in_france_relance=True) text = self.cleaned_data.get('text', None) if text: text_unaccented = remove_accents(text) query = self.parse_query(text_unaccented) qs = qs \ .filter(search_vector_unaccented=query) \ .annotate(rank=SearchRank(F('search_vector_unaccented'), query)) targeted_audiences = self.cleaned_data.get('targeted_audiences', None) if targeted_audiences: qs = qs.filter(targeted_audiences__overlap=targeted_audiences) categories = self.cleaned_data.get('categories', None) if categories: qs = qs.filter(categories__in=categories) programs = self.cleaned_data.get('programs', None) if programs: qs = qs.filter(programs__in=programs) # We filter by theme only if no categories were provided. # This is to handle the following edge case: on the multi-step search # form, the user selects a theme, then on the last step, doesn't select # any categories and just click "Search". themes = self.cleaned_data.get('themes', None) if themes and not categories: qs = qs.filter(categories__theme__in=themes) backers = self.cleaned_data.get('backers', None) if backers: qs = qs.filter( Q(financers__in=backers) | Q(instructors__in=backers)) origin_url = self.cleaned_data.get('origin_url', None) if origin_url: qs = qs.filter(origin_url=origin_url) if apply_generic_aid_filter: qs = self.generic_aid_filter(qs) return qs def parse_query(self, raw_query): """Process a raw query and returns a `SearchQuery`. In Postgres, you converts a search query into a `tsquery` object that is matched against a `tsvector` object. The main method to get a `tsquery` is to use the function `plainto_tsquery` that is designed to transform unformatted text and generates a `tsquery` with tokens separated by `AND`. That is the default function used by Django when you create a `SearchQuery` object. If you want to create a `ts_query` with other boolean operators, you have two main solutions: - use the `to_tsquery` method that is not made to handle raw data - create several `ts_query` objects and combine them using boolean operators. This is the second solution we are using. By default, terms are made mandatory. Terms with a " ou " in between are optional. """ all_terms = filter(None, raw_query.lower().split(',')) all_terms = list(all_terms) all_terms = [term.strip(' ') for term in all_terms] next_operator = operator.or_ invert = False query = None for term in all_terms: if len(term.split(' ')) > 1: list_sub_term = term.split(' ') sub_query = None for sub_term in list_sub_term: next_operator = operator.and_ if sub_query is None: sub_query = SearchQuery(sub_term, config='french', invert=invert) else: sub_query = next_operator( sub_query, SearchQuery(sub_term, config='french', invert=invert)) if query is None: query = sub_query else: next_operator = operator.or_ query = next_operator(query, sub_query) else: if query is None: query = SearchQuery(term, config='french', invert=invert) else: query = next_operator( query, SearchQuery(term, config='french', invert=invert)) next_operator = operator.or_ invert = False return query def get_order_fields(self, qs, has_highlighted_aids=False, pre_order=None): """On which fields must this queryset be sorted?.""" # Default results order # show the narrower perimeter first, then aids with a deadline order_fields = ['perimeter__scale', 'submission_deadline'] # If the search comes from a PP if pre_order and has_highlighted_aids: if pre_order == 'publication_date': order_fields = ['-is_highlighted_aid'] + ['-date_published' ] + order_fields elif pre_order == 'submission_deadline': order_fields = ['-is_highlighted_aid' ] + ['submission_deadline'] + order_fields elif has_highlighted_aids: order_fields = ['-is_highlighted_aid'] + order_fields # If the user submitted a text query, we order by query rank first text = self.cleaned_data.get('text', None) if text: order_fields = ['-rank'] + order_fields # If the user requested a manual order by publication date manual_order = self.cleaned_data.get('order_by', 'relevance') if manual_order == 'publication_date': order_fields = ['-date_published'] + order_fields elif manual_order == 'submission_deadline': order_fields = ['submission_deadline'] + order_fields return order_fields def order_queryset(self, qs, has_highlighted_aids=False, pre_order=None): """Set the order value on the queryset.""" qs = qs.order_by(*self.get_order_fields( qs, has_highlighted_aids=has_highlighted_aids, pre_order=pre_order, )) # noqa return qs def perimeter_filter(self, qs, search_perimeter): """Filter queryset depending on the given perimeter. When we search for a given perimeter, we must return all aids: - where the perimeter is wider and contains the searched perimeter ; - where the perimeter is smaller and contained by the search perimeter ; E.g if we search for aids in "Hérault (department), we must display all aids that are applicable to: - Hérault ; - Occitanie ; - France ; - Europe ; - M3M (and all other epcis in Hérault) ; - Montpellier (and all other communes in Hérault) ; """ perimeter_qs = get_all_related_perimeter_ids(search_perimeter.id) qs = qs.filter(perimeter__in=perimeter_qs) return qs def generic_aid_filter(self, qs): """ We should never have both the generic aid and it's local version together on search results. Which one should be removed from the result ? It depends... We consider the scale perimeter associated to the local aid. - When searching on a wider area than the local aid's perimeter, then we display the generic version. - When searching on a smaller area than the local aid's perimeter, then we display the local version. """ search_perimeter = self.cleaned_data.get('perimeter', None) # We will consider local aids for which the associated generic # aid is listed in the results - We should consider excluding a # local aid, only when it's generic aid is listed. generic_aids = qs.generic_aids() local_aids = qs.local_aids().filter(generic_aid__in=generic_aids) # We use a python list for better performance local_aids_list = local_aids.values_list('pk', 'perimeter__scale', 'generic_aid__pk') aids_to_exclude = [] if not search_perimeter: # If the user does not specify a search perimeter, then we go wide. search_smaller = False search_wider = True for aid_id, perimeter_scale, generic_aid_id in local_aids_list: if search_perimeter: search_smaller = search_perimeter.scale <= perimeter_scale search_wider = search_perimeter.scale > perimeter_scale # If the search perimeter is smaller or matches exactly the local # perimeter, then it's relevant to keep the local and exclude # the generic aid.Excluding the generic aid takes precedence # over excluding the local aid. if search_smaller: aids_to_exclude.append(generic_aid_id) elif search_wider: # If the search perimeter is wider than the local perimeter # then it more relevant to keep the generic aid and exclude the # the local one. aids_to_exclude.append(aid_id) qs = qs.exclude(pk__in=aids_to_exclude) return qs
class AidEditForm(BaseAidForm): programs = forms.ModelMultipleChoiceField(label="Programme d'aides", queryset=Program.objects.all(), required=False) financers = AutocompleteModelMultipleChoiceField( label="Porteurs d'aides", queryset=Backer.objects.all(), required=False, help_text= 'Saisissez quelques caractères et sélectionnez une valeur parmi les suggestions.' ) # noqa financer_suggestion = forms.CharField( label='Suggérer un nouveau porteur', max_length=256, required=False, help_text= 'Suggérez un porteur si vous ne trouvez pas votre choix dans la liste principale.' ) # noqa instructors = AutocompleteModelMultipleChoiceField( label="Porteurs d'aides", queryset=Backer.objects.all(), required=False, help_text= 'Saisissez quelques caractères et sélectionnez une valeur parmi les suggestions.' ) # noqa instructor_suggestion = forms.CharField( label='Suggérer un nouvel instructeur', max_length=256, required=False, help_text= 'Suggérez un instructeur si vous ne trouvez pas votre choix dans la liste principale.' ) # noqa perimeter = AutocompleteModelChoiceField( queryset=Perimeter.objects.all(), label="Zone géographique couverte par l'aide", help_text=''' La zone géographique sur laquelle l'aide est disponible.<br /> Exemples de zones valides : <ul> <li>France</li> <li>Bretagne (Région)</li> <li>Métropole du Grand Paris (EPCI)</li> <li>Outre-mer</li> <li>Wallis et Futuna</li> <li>Massif Central</li> </ul> ''') perimeter_suggestion = forms.CharField( label='Vous ne trouvez pas de zone géographique appropriée ?', max_length=256, required=False, help_text=''' Si vous ne trouvez pas de zone géographique suffisamment précise dans la liste existante, spécifiez « France » et décrivez brièvement ici le périmètre souhaité. ''') categories = CategoryMultipleChoiceField( label="Thématiques de l'aide", required=False, help_text= "Sélectionnez la ou les thématiques associées à votre aide. N'hésitez pas à en choisir plusieurs." ) # noqa class Meta: model = Aid fields = [ 'name', 'name_initial', 'short_title', 'description', 'categories', 'project_examples', 'targeted_audiences', 'financers', 'financer_suggestion', 'instructors', 'instructor_suggestion', 'in_france_relance', 'recurrence', 'start_date', 'predeposit_date', 'submission_deadline', 'perimeter', 'perimeter_suggestion', 'is_call_for_project', 'programs', 'aid_types', 'subvention_rate', 'subvention_comment', 'loan_amount', 'recoverable_advance_amount', 'other_financial_aid_comment', 'mobilization_steps', 'destinations', 'eligibility', 'origin_url', 'application_url', 'contact', 'local_characteristics', ] widgets = { 'mobilization_steps': MultipleChoiceFilterWidget, 'destinations': MultipleChoiceFilterWidget, 'targeted_audiences': MultipleChoiceFilterWidget, 'aid_types': MultipleChoiceFilterWidget, 'start_date': forms.TextInput(attrs={ 'type': 'date', 'placeholder': 'jj/mm/aaaa' }), 'predeposit_date': forms.TextInput(attrs={ 'type': 'date', 'placeholder': 'jj/mm/aaaa' }), 'submission_deadline': forms.TextInput(attrs={ 'type': 'date', 'placeholder': 'jj/mm/aaaa' }) } def __init__(self, *args, **kwargs): # The form validation rule will change depending on the # new aid status. self.requested_status = kwargs.pop('requested_status', None) super().__init__(*args, **kwargs) if self.requested_status is None: self.requested_status = self.instance.status if 'subvention_rate' in self.fields: range_widgets = self.fields['subvention_rate'].widget.widgets range_widgets[0].attrs['placeholder'] = 'Taux de subvention min.' range_widgets[1].attrs['placeholder'] = 'Taux de subvention max.' if 'mobilization_steps' in self.fields: self.fields['mobilization_steps'].required = True if 'targeted_audiences' in self.fields: self.fields['targeted_audiences'].required = True if 'aid_types' in self.fields: self.fields['aid_types'].required = True if 'categories' in self.fields: self.fields['categories'].required = True def full_clean(self): if self.requested_status == 'draft': for field_name in self.fields.keys(): if field_name == 'name': continue self.fields[field_name].required = False return super().full_clean() def clean(self): """Validation routine (frontend form only).""" data = super().clean() # If the aid is saved as draft, don't perform any data validation if self.requested_status == 'draft': return data if 'recurrence' in data and data['recurrence']: recurrence = data['recurrence'] submission_deadline = data.get('submission_deadline', None) if recurrence != 'ongoing' and not submission_deadline: msg = 'Sauf pour les aides permanentes, veuillez indiquer la date limite de soumission.' # noqa self.add_error( 'submission_deadline', ValidationError(msg, code='missing_submission_deadline')) if 'financers' in self.fields: if not any( (data.get('financers'), data.get('financer_suggestion'))): msg = "Merci d'indiquer un porteur d'aide." self.add_error('financers', msg) return data
class AidEditForm(BaseAidForm): programs = forms.ModelMultipleChoiceField(label=_('Aid program'), queryset=Program.objects.all(), required=False) financers = AutocompleteModelMultipleChoiceField( label=_('Backers'), queryset=Backer.objects.all(), required=False, help_text=_('Type a few characters and select a value among the list')) financer_suggestion = forms.CharField( label=_('Suggest a new backer'), max_length=256, required=False, help_text=_('Suggest a backer if you don\'t find ' 'the correct choice in the main list.')) instructors = AutocompleteModelMultipleChoiceField( label=_('Backers'), queryset=Backer.objects.all(), required=False, help_text=_('Type a few characters and select a value among the list')) instructor_suggestion = forms.CharField( label=_('Suggest a new instructor'), max_length=256, required=False, help_text=_('Suggest an instructor if you don\'t find ' 'the correct choice in the main list.')) perimeter = AutocompleteModelChoiceField(queryset=Perimeter.objects.all(), label=_('Targeted area'), help_text=_(''' The geographical zone where the aid is available.<br /> Example of valid zones: <ul> <li>France</li> <li>Bretagne (Région)</li> <li>Métropole du Grand Paris (EPCI)</li> <li>Outre-mer</li> <li>Wallis et Futuna</li> <li>Massif Central</li> </ul> ''')) perimeter_suggestion = forms.CharField( label=_('Can\'t find a valid targeted area?'), max_length=256, required=False, help_text=_(''' If you can't find a corresponding targeted area amongst the existing perimeter list, just choose "France" and briefly describe here your aid actual target area. ''')) categories = CategoryMultipleChoiceField( label=_('Aid categories'), help_text=_('Choose one or several categories that match your aid.'), required=False) class Meta: model = Aid fields = [ 'name', 'short_title', 'description', 'categories', 'project_examples', 'targeted_audiences', 'financers', 'financer_suggestion', 'instructors', 'instructor_suggestion', 'in_france_relance', 'recurrence', 'start_date', 'predeposit_date', 'submission_deadline', 'perimeter', 'perimeter_suggestion', 'is_call_for_project', 'programs', 'aid_types', 'subvention_rate', 'subvention_comment', 'mobilization_steps', 'destinations', 'eligibility', 'origin_url', 'application_url', 'contact', 'local_characteristics', ] widgets = { 'mobilization_steps': MultipleChoiceFilterWidget, 'destinations': MultipleChoiceFilterWidget, 'targeted_audiences': MultipleChoiceFilterWidget, 'aid_types': MultipleChoiceFilterWidget, 'start_date': forms.TextInput(attrs={ 'type': 'date', 'placeholder': _('yyyy-mm-dd') }), 'predeposit_date': forms.TextInput(attrs={ 'type': 'date', 'placeholder': _('yyyy-mm-dd') }), 'submission_deadline': forms.TextInput(attrs={ 'type': 'date', 'placeholder': _('yyyy-mm-dd') }) } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if 'subvention_rate' in self.fields: range_widgets = self.fields['subvention_rate'].widget.widgets range_widgets[0].attrs['placeholder'] = _('Min. subvention rate') range_widgets[1].attrs['placeholder'] = _('Max. subvention rate') if 'mobilization_steps' in self.fields: self.fields['mobilization_steps'].required = True if 'categories' in self.fields: self.fields['categories'].required = True def clean(self): """Validation routine (frontend form only).""" data = super().clean() if 'recurrence' in data and data['recurrence']: recurrence = data['recurrence'] submission_deadline = data.get('submission_deadline', None) if recurrence != 'ongoing' and not submission_deadline: msg = _( 'Unless the aid is ongoing, you must indicate the submission deadline.' ) # noqa self.add_error( 'submission_deadline', ValidationError(msg, code='missing_submission_deadline')) return data
class BaseAidSearchForm(forms.Form): """Main form for search engine.""" AID_CATEGORY_CHOICES = ( ('', ''), ('funding', _('Funding')), ('non-funding', _('Non-funding')), ) AID_TYPE_CHOICES = ( ('financial', _('Financial aid')), ('technical', _('Engineering aid')), ) ORDER_BY = ( ('relevance', _('Sort: relevance')), ('publication_date', _('Sort: publication date')), ('submission_deadline', _('Sort: submission deadline')), ) CATEGORIES_QS = Category.objects \ .select_related('theme') \ .order_by('theme__name', 'name') text = forms.CharField( label=_('Text search'), required=False, widget=forms.TextInput( attrs={'placeholder': _('Aid title, keyword, etc.')})) apply_before = forms.DateField( label=_('Apply before…'), required=False, widget=forms.TextInput(attrs={'type': 'date'})) aid_type = forms.MultipleChoiceField(label=_('Aid type'), choices=AID_TYPE_CHOICES, required=False, widget=forms.CheckboxSelectMultiple) financial_aids = forms.MultipleChoiceField( label=_('Financial aids'), required=False, choices=FINANCIAL_AIDS, widget=forms.CheckboxSelectMultiple) technical_aids = forms.MultipleChoiceField( label=_('Engineering aids'), required=False, choices=TECHNICAL_AIDS, widget=forms.CheckboxSelectMultiple) mobilization_step = forms.MultipleChoiceField( label=_('Project progress'), required=False, choices=Aid.STEPS, widget=forms.CheckboxSelectMultiple) destinations = forms.MultipleChoiceField( label=_('Concerned actions'), required=False, choices=Aid.DESTINATIONS, widget=forms.CheckboxSelectMultiple) call_for_projects_only = forms.BooleanField( label=_('Call for projects only'), required=False) backers = AutocompleteModelMultipleChoiceField( label=_('Backers'), queryset=Backer.objects.all(), required=False) themes = forms.ModelMultipleChoiceField(label=_('Themes'), queryset=Theme.objects.all(), to_field_name='slug', required=False, widget=forms.MultipleHiddenInput) categories = CategoryMultipleChoiceField( label=_('Themes'), # Not a mistake queryset=CATEGORIES_QS, to_field_name='slug', required=False) targeted_audiances = forms.MultipleChoiceField( label=_('You are seeking aids for…'), required=False, choices=Aid.AUDIANCES, widget=forms.CheckboxSelectMultiple) perimeter = AutocompleteModelChoiceField(queryset=Perimeter.objects.all(), label=_('Your territory'), required=False) # This field is not related to the search, but is submitted # in views embedded through an iframe. integration = forms.CharField(required=False, widget=forms.HiddenInput) # This field is used to sort results order_by = forms.ChoiceField(label=_('Order by'), required=False, choices=ORDER_BY) def clean_zipcode(self): zipcode = self.cleaned_data['zipcode'] if zipcode and re.match(r'\d{5}', zipcode) is None: msg = _('This zipcode seems invalid') raise forms.ValidationError(msg) return zipcode def filter_queryset(self, qs=None): """Filter querysets depending of input data.""" # If no qs was passed, just start with all published aids if qs is None: qs = Aid.objects.published().open() if not self.is_bound: return qs # Populate cleaned_data if not hasattr(self, 'cleaned_data'): self.full_clean() perimeter = self.cleaned_data.get('perimeter', None) if perimeter: qs = self.perimeter_filter(qs, perimeter) mobilization_steps = self.cleaned_data.get('mobilization_step', None) if mobilization_steps: qs = qs.filter(mobilization_steps__overlap=mobilization_steps) # Those two form fields are split for form readability, # but they relate to a single model field. financial_aids = self.cleaned_data.get('financial_aids', []) technical_aids = self.cleaned_data.get('technical_aids', []) aid_types = financial_aids + technical_aids aid_type = self.cleaned_data['aid_type'] if 'financial' in aid_type: aid_types += Aid.FINANCIAL_AIDS if 'technical' in aid_type: aid_types += Aid.TECHNICAL_AIDS if aid_types: qs = qs.filter(aid_types__overlap=aid_types) destinations = self.cleaned_data.get('destinations', None) if destinations: qs = qs.filter(destinations__overlap=destinations) apply_before = self.cleaned_data.get('apply_before', None) if apply_before: qs = qs.filter(submission_deadline__lt=apply_before) call_for_projects_only = self.cleaned_data.get( 'call_for_projects_only', False) if call_for_projects_only: qs = qs.filter(is_call_for_project=True) text = self.cleaned_data.get('text', None) if text: query = self.parse_query(text) qs = qs \ .filter(search_vector=query) \ .annotate(rank=SearchRank(F('search_vector'), query)) targeted_audiances = self.cleaned_data.get('targeted_audiances', None) if targeted_audiances: qs = qs.filter(targeted_audiances__overlap=targeted_audiances) categories = self.cleaned_data.get('categories', None) if categories: qs = qs.filter(categories__in=categories) # We filter by theme only if no categories were provided. # This is to handle the following edge case: on the multi-step search # form, the user selects a theme, then on the last step, doesn't select # any categories and just click "Search". themes = self.cleaned_data.get('themes', None) if themes and not categories: qs = qs.filter(categories__theme__in=themes) backers = self.cleaned_data.get('backers', None) if backers: qs = qs.filter( Q(financers__in=backers) | Q(instructors__in=backers)) return qs def parse_query(self, raw_query): """Process a raw query and returns a `SearchQuery`. In Postgres, you converts a search query into a `tsquery` object that is matched against a `tsvector` object. The main method to get a `tsquery` is to use the function `plainto_tsquery` that is designed to transform unformatted text and generates a `tsquery` with tokens separated by `AND`. That is the default function used by Django when you create a `SearchQuery` object. If you want to create a `ts_query` with other boolean operators, you have two main solutions: - use the `to_tsquery` method that is not made to handle raw data - create several `ts_query` objects and combine them using boolean operators. This is the second solution we are using. By default, terms are optional. Terms with a "+" in between are made mandatory. Terms preceded with a "-" are filtered out. """ all_terms = filter(None, raw_query.split(' ')) next_operator = operator.or_ invert = False query = None for term in all_terms: if term == '+': next_operator = operator.and_ continue if term == '-': next_operator = operator.and_ invert = True continue if query is None: query = SearchQuery(term, config='french', invert=invert) else: query = next_operator( query, SearchQuery(term, config='french', invert=invert)) next_operator = operator.or_ invert = False return query def get_order_fields(self, qs): """On which fields must this queryset be sorted?.""" # Default results order # We show the narrower perimet first, then aids with a deadline order_fields = ['perimeter__scale', 'submission_deadline'] # If the user submitted a text query, we order by query rank first text = self.cleaned_data.get('text', None) if text: order_fields = ['-rank'] + order_fields # If the user requested a manual order by publication date manual_order = self.cleaned_data.get('order_by', 'relevance') if manual_order == 'publication_date': order_fields = ['-date_published'] + order_fields elif manual_order == 'submission_deadline': order_fields = ['submission_deadline'] + order_fields return order_fields def order_queryset(self, qs): """Set the order value on the queryset.""" qs = qs.order_by(*self.get_order_fields(qs)) return qs def perimeter_filter(self, qs, search_perimeter): """Filter queryset depending on the given perimeter. When we search for a given perimeter, we must return all aids: - where the perimeter is wider and contains the searched perimeter ; - where the perimeter is smaller and contained by the search perimeter ; E.g if we search for aids in "Hérault (department), we must display all aids that are applicable to: - Hérault ; - Occitanie ; - France ; - Europe ; - M3M (and all other epcis in Hérault) ; - Montpellier (and all other communes in Hérault) ; """ # Note: the original way we adressed this was more straightforward, # but we got very very bad perf results (like, queries with very slow # execution times > 30s). # Thus, we add to "help" a little Postgres execution planner. # We just need to efficiently get a list of all perimeter ids related # to the current query. Through = Perimeter.contained_in.through contains_id = Through.objects \ .filter(from_perimeter_id=search_perimeter.id) \ .values('to_perimeter_id') \ .distinct() contained_id = Through.objects \ .filter(to_perimeter_id=search_perimeter.id) \ .values('from_perimeter_id') \ .distinct() q_exact_match = Q(id=search_perimeter.id) q_contains = Q(id__in=contains_id) q_contained = Q(id__in=contained_id) perimeter_qs = Perimeter.objects.filter( q_exact_match | q_contains | q_contained).values('id').distinct() qs = qs.filter(perimeter__in=perimeter_qs) return qs