# # combo - content management system # Copyright (C) 2014-2015 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import collections import copy import logging from django.conf import settings from django.db import models from django.db.models import JSONField from django.forms import Select from django.forms import models as model_forms from django.template import Context, RequestContext, Template, TemplateSyntaxError, VariableDoesNotExist from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django.utils.translation import pgettext_lazy from requests.exceptions import RequestException from combo import utils from combo.data.library import register_cell_class from combo.data.models import CellBase, Page from combo.utils import NothingInCacheException, is_bot, requests from combo.utils.misc import is_portal_agent from .utils import get_matching_pages_from_card_slug, get_wcs_json, get_wcs_services, is_wcs_enabled invalid_reason_codes = { 'wcs_form_not_defined': _('No form set'), 'wcs_form_not_found': _('Invalid form'), 'wcs_card_not_defined': _('No card model set'), 'wcs_card_not_found': _('Invalid card model'), 'wcs_card_relation_not_found': _('Invalid Card Identifier'), 'wcs_category_not_defined': _('No category set'), 'wcs_category_not_found': _('Invalid category'), 'wcs_site_not_found': _('Invalid site'), 'wcs_data_failure': _('Failed to get data'), } def get_formdef_css_classes(formdef): classes = [] if formdef.get('redirection'): classes.append('is-redirection') if formdef.get('authentication_required'): classes.append('required-authentication') for authn_context in formdef.get('required_authentication_contexts') or []: classes.append('required-%s-authentication' % authn_context) for keyword in formdef.get('keywords') or []: classes.append('keyword-%s' % slugify(keyword)) return classes @register_cell_class class WcsFormCell(CellBase): formdef_reference = models.CharField(_('Form'), max_length=150) cached_title = models.CharField(_('Title'), max_length=250) cached_url = models.URLField(_('URL')) cached_json = JSONField(blank=True, default=dict) default_template_name = 'combo/wcs/form.html' add_as_link_label = _('add a form link') add_link_label = _('New form link') edit_link_label = _('Edit form link') add_as_link_code = 'form-link' invalid_reason_codes = invalid_reason_codes is_enabled = classmethod(is_wcs_enabled) class Meta: verbose_name = _('Form Link') def get_default_form_class(self): from .forms import WcsFormCellForm return WcsFormCellForm def get_form_class_for_link_list_cell(self): from .forms import WcsFormForLinkListCellForm return WcsFormForLinkListCellForm def save(self, *args, **kwargs): if 'update_fields' in kwargs: # don't populate the cache return super().save(*args, **kwargs) def populate_cache(): if self.formdef_reference: wcs_key, form_slug = self.formdef_reference.split(':') wcs_site = get_wcs_services().get(wcs_key) forms_response_json = get_wcs_json(wcs_site, 'api/formdefs/') if not forms_response_json or forms_response_json.get('err') == 1: # can not retrieve data, don't report cell as invalid self.mark_as_valid() return form_found = False for form in forms_response_json.get('data') or []: slug = form.get('slug') if slug == form_slug: self.cached_title = form.get('title')[:250] self.cached_url = form.get('url') self.cached_json = form self.save(update_fields=['cached_title', 'cached_url', 'cached_json']) form_found = True break if form_found: self.mark_as_valid() return else: return self.mark_as_invalid('wcs_form_not_found') else: return self.mark_as_invalid('wcs_form_not_defined') super().save(*args, **kwargs) populate_cache() def get_cell_extra_context(self, context): extra_context = super().get_cell_extra_context(context) extra_context['slug'] = self.formdef_reference.split(':')[-1] extra_context['title'] = self.cached_title extra_context['url'] = self.cached_url extra_context['request_is_a_bot'] = is_bot(context.get('request')) extra_context['is_form'] = True if self.cached_json: extra_context['description'] = mark_safe(self.cached_json.get('description', '')) extra_context['css_classes'] = get_formdef_css_classes(self.cached_json) for attribute in self.cached_json: if attribute not in context: extra_context[attribute] = self.cached_json.get(attribute) return extra_context def get_additional_label(self): if not self.cached_title: return return self.cached_title def render_for_search(self): return '' def get_external_links_data(self): if not (self.cached_url and self.cached_title): return [] text = '' if self.cached_json: text = ' '.join( [self.cached_json.get('description', ''), ' '.join(self.cached_json.get('keywords', []))] ).strip() return [ { 'url': self.cached_url, 'title': self.cached_title, 'text': text, } ] def get_slug_for_asset(self): return self.formdef_reference def get_label_for_asset(self): return str(self) def get_asset_slot_key(self, key): # for legacy return 'wcs:form:%s:%s' % (key, self.get_slug_for_asset()) def get_asset_slot_templates(self): # for legacy if settings.WCS_FORM_ASSET_SLOTS: return settings.WCS_FORM_ASSET_SLOTS return super().get_asset_slot_templates() class WcsCommonCategoryCell(CellBase): is_enabled = classmethod(is_wcs_enabled) category_reference = models.CharField(_('Category'), max_length=150) cached_title = models.CharField(_('Title'), max_length=150) cached_description = models.TextField(_('Description'), blank=True) cached_url = models.URLField(_('Cached URL')) invalid_reason_codes = invalid_reason_codes class Meta: abstract = True def save(self, *args, **kwargs): if 'update_fields' in kwargs: # don't populate the cache return super().save(*args, **kwargs) def populate_cache(): if self.category_reference: wcs_key, category_slug = self.category_reference.split(':') wcs_site = get_wcs_services().get(wcs_key) categories_response_json = get_wcs_json(wcs_site, 'api/categories/') if not categories_response_json or categories_response_json.get('err') == 1: # can not retrieve data, don't report cell as invalid self.mark_as_valid() return category_found = False for category in categories_response_json.get('data') or []: slug = category.get('slug') if slug == category_slug: self.cached_title = category.get('title') self.cached_description = category.get('description') or '' self.cached_url = category.get('url') self.save(update_fields=['cached_title', 'cached_description', 'cached_url']) category_found = True break if category_found: self.mark_as_valid() return else: return self.mark_as_invalid('wcs_category_not_found') else: return self.mark_as_invalid('wcs_category_not_defined') super().save(*args, **kwargs) populate_cache() def get_additional_label(self): if not self.cached_title: return return self.cached_title def get_slug_for_asset(self): return self.category_reference def get_label_for_asset(self): return str(self) def get_asset_slot_key(self, key): # for legacy return 'wcs:category:%s:%s' % (key, self.get_slug_for_asset()) def get_asset_slot_templates(self): # for legacy if settings.WCS_CATEGORY_ASSET_SLOTS: return settings.WCS_CATEGORY_ASSET_SLOTS return super().get_asset_slot_templates() @register_cell_class class WcsCategoryCell(WcsCommonCategoryCell): default_template_name = 'combo/wcs/category.html' class Meta: verbose_name = _('Category Link') def get_default_form_class(self): from .forms import WcsCategoryCellForm return WcsCategoryCellForm def get_cell_extra_context(self, context): context = super().get_cell_extra_context(context) if not self.category_reference: return context context['slug'] = self.category_reference.split(':')[-1] context['title'] = self.cached_title context['description'] = self.cached_description context['url'] = self.cached_url return context class WcsBlurpMixin: is_enabled = classmethod(is_wcs_enabled) cache_duration = 5 api_url = None warn_on_4xx = True invalid_reason_codes = invalid_reason_codes def get_api_url(self, context): return self.api_url def get_api_url_for_site(self, context, wcs_slug): return self.get_api_url(context) def get_data(self, context): if context.get('placeholder_search_mode'): # don't call webservices when we're just looking for placeholders return {} if self.wcs_site: try: wcs_sites = {self.wcs_site: get_wcs_services()[self.wcs_site]} except KeyError: # in case of the site disappeared from settings return {} else: wcs_sites = get_wcs_services() result = {} returns = set() for wcs_slug, _wcs_site in wcs_sites.items(): api_url = self.get_api_url_for_site(context, wcs_slug) if not api_url: # nothing to call for this site continue url = _wcs_site.get('url') if not url.endswith('/'): url += '/' wcs_site = copy.deepcopy(_wcs_site) result[wcs_slug] = wcs_site wcs_site['base_url'] = url wcs_site['slug'] = wcs_slug try: response = requests.get( api_url, remote_service=wcs_site, user=None if getattr(self, 'without_user', False) else getattr(context.get('request'), 'user', None), without_user=getattr(self, 'without_user', False), cache_duration=self.cache_duration, raise_if_not_cached=not (context.get('synchronous')), log_errors=False, ) returns.add(response.status_code) response.raise_for_status() except RequestException: continue json_response = response.json() if json_response.get('err', 0) == 0: wcs_site['data'] = json_response['data'] else: # skip errors continue # and mark all items with the site info for item in wcs_site['data']: item['site_slug'] = wcs_slug if returns and 200 not in returns: # not a single valid answer logging.debug('failed to get data from any %s (%r)', api_url, returns) if all([400 <= r < 500 for r in returns]) and self.warn_on_4xx: # only 4xx errors, not a user cell, report the cell as invalid self.mark_as_invalid('wcs_data_failure', force=False) else: self.mark_as_valid() else: self.mark_as_valid() return result def get_cell_extra_context(self, context): return {self.variable_name: self.get_data(context)} class WcsDataBaseCell(CellBase, WcsBlurpMixin): is_enabled = classmethod(is_wcs_enabled) wcs_site = models.CharField(_('Site'), max_length=50, blank=True) class Meta: abstract = True def get_additional_label(self): wcs_sites = {x: y.get('title') for x, y in get_wcs_services().items()} if len(wcs_sites.keys()) < 2: return '' if self.wcs_site in wcs_sites: return wcs_sites[self.wcs_site] return _('All Sites') def get_form_fields(self): if len(get_wcs_services()) == 1: return [] return ['wcs_site'] def get_form_widgets(self): if len(get_wcs_services()) == 1: return {} combo_wcs_sites = [('', _('All'))] wcs_sites = [(x, y.get('title')) for x, y in get_wcs_services().items()] wcs_sites.sort(key=lambda x: x[1]) combo_wcs_sites.extend(wcs_sites) return {'wcs_site': Select(choices=combo_wcs_sites)} def get_default_form_class(self): fields = self.get_form_fields() if not fields: return None return model_forms.modelform_factory(self.__class__, fields=fields, widgets=self.get_form_widgets()) def get_cell_extra_context(self, context): extra_context = super().get_cell_extra_context(context) extra_context.update(WcsBlurpMixin.get_cell_extra_context(self, context)) return extra_context class WcsUserDataBaseCell(WcsDataBaseCell): warn_on_4xx = False user_dependant = True class Meta: abstract = True def is_visible(self, request, **kwargs): user = getattr(request, 'user', None) if not user or user.is_anonymous: return False return super().is_visible(request, **kwargs) class CategoriesAndWcsSiteValidityMixin: def check_validity(self): if self.wcs_site and self.wcs_site not in get_wcs_services(): self.mark_as_invalid('wcs_site_not_found') return categories = self.categories.get('data', []) if not categories: self.mark_as_valid() return for category_reference in categories: wcs_key, category_slug = category_reference.split(':') wcs_site = get_wcs_services().get(wcs_key) categories_response_json = get_wcs_json(wcs_site, 'api/categories/') if not categories_response_json or categories_response_json.get('err') == 1: # can not retrieve data, don't report cell as invalid continue category_found = any( [c.get('slug') == category_slug for c in categories_response_json.get('data') or []] ) if not category_found: self.mark_as_invalid('wcs_category_not_found') return self.mark_as_valid() class CategoriesFilteringMixin: def get_api_url_for_site(self, context, wcs_slug): url = self.get_api_url(context) if self.categories and self.categories.get('data'): categories_by_site = collections.defaultdict(list) for category in self.categories['data']: key, slug = category.split(':') categories_by_site[key].append(slug) if not categories_by_site.get(wcs_slug): return None separator = '?' if '?' not in url else '&' url += '%scategory_slugs=%s' % (separator, ','.join(categories_by_site.get(wcs_slug))) return url @register_cell_class class WcsCurrentFormsCell(CategoriesAndWcsSiteValidityMixin, CategoriesFilteringMixin, WcsUserDataBaseCell): variable_name = 'user_forms' loading_message = _('Loading forms...') categories = JSONField(_('Categories'), blank=True, default=dict) custom_title = models.CharField(_('Custom Title'), max_length=150, blank=True) current_forms = models.BooleanField(_('Current Forms'), default=True) done_forms = models.BooleanField(_('Done Forms'), default=False) include_drafts = models.BooleanField(_('Include drafts'), default=False) include_forms_user_can_access = models.BooleanField( _('Include forms to which the user can access'), default=False ) class Meta: verbose_name = _('User Forms') def get_default_form_class(self): from .forms import WcsCurrentFormsCellForm return WcsCurrentFormsCellForm def get_api_url(self, context): url = '/api/user/forms/' user = self.get_concerned_user(context) if user and not user.is_anonymous: user_name_id = user.get_name_id() if user_name_id: url = '/api/users/%s/forms' % user_name_id if self.current_forms and self.done_forms: url += '?status=all' elif self.done_forms: url += '?status=done' else: url += '?status=open' if self.include_forms_user_can_access: url += '&include-accessible=on' if self.include_drafts: url += '&include-drafts=on' url += '&limit=%s&sort=desc' % settings.WCS_USER_FORMS_LIMIT return url @property def default_template_name(self): if self.current_forms and self.done_forms: return 'combo/wcs/user_all_forms.html' if self.done_forms: return 'combo/wcs/user_done_forms.html' return 'combo/wcs/current_forms.html' def get_additional_label(self): initial_label = super().get_additional_label() if self.include_drafts: if self.current_forms and self.done_forms: label = _('All Forms and Drafts') elif self.done_forms: label = _('Done Forms and Drafts') else: label = _('Current Forms and Drafts') else: if self.current_forms and self.done_forms: label = _('All Forms') elif self.done_forms: label = _('Done Forms') else: label = _('Current Forms') if initial_label: return '%s - %s' % (initial_label, label) return label def get_cell_extra_context(self, context): context = super().get_cell_extra_context(context) context['current_forms'] = context['user_forms'] # legacy # regroup all forms in a flat list context['forms'] = [] for wcs_site in context['user_forms']: if not context['user_forms'].get(wcs_site): continue if not context['user_forms'][wcs_site].get('data'): continue context['forms'].extend(context['user_forms'][wcs_site]['data']) return context @register_cell_class class WcsCurrentDraftsCell(CategoriesAndWcsSiteValidityMixin, CategoriesFilteringMixin, WcsUserDataBaseCell): variable_name = 'current_drafts' default_template_name = 'combo/wcs/current_drafts.html' loading_message = _('Loading drafts...') categories = JSONField(_('Categories'), blank=True, default=dict) class Meta: verbose_name = _('Current Drafts') def get_default_form_class(self): from .forms import WcsCurrentDraftsCellForm return WcsCurrentDraftsCellForm def get_api_url(self, context): user = self.get_concerned_user(context) if user and not user.is_anonymous: user_name_id = user.get_name_id() if user_name_id: return '/api/users/%s/drafts' % user_name_id return '/api/user/drafts' def get_cell_extra_context(self, context): context = super().get_cell_extra_context(context) # regroup all forms in a flat list context['drafts'] = [] for wcs_site in context['current_drafts']: if not context['current_drafts'].get(wcs_site): continue if not context['current_drafts'][wcs_site].get('data'): continue context['drafts'].extend(context['current_drafts'][wcs_site]['data']) return context @register_cell_class class WcsFormsOfCategoryCell(WcsCommonCategoryCell, WcsBlurpMixin): ordering = models.CharField( _('Order'), max_length=20, default='alpha', blank=False, choices=[ ('alpha', _('Default (alphabetical)')), ('popularity', _('Popularity')), ('manual', _('Manual')), ], ) manual_order = JSONField( blank=True, default=dict, verbose_name=_('Manual Order'), help_text=_('Use drag and drop to reorder forms'), ) limit = models.PositiveSmallIntegerField(_('Limit'), null=True, blank=True) class Meta: verbose_name = _('Forms of Category') variable_name = 'forms' default_template_name = 'combo/wcs/forms_of_category.html' cache_duration = 120 def get_default_form_class(self): from .forms import WcsFormsOfCategoryCellForm return WcsFormsOfCategoryCellForm @property def wcs_site(self): return self.category_reference.split(':')[0] def get_api_url(self, context): api_url = '/api/categories/%s/formdefs/' % self.category_reference.split(':')[1] if self.ordering == 'popularity': api_url += '?include-count=on' return api_url def is_relevant(self, context): return bool(self.category_reference) def check_validity(self): if not self.category_reference: return wcs_key, category_slug = self.category_reference.split(':') wcs_site = get_wcs_services().get(wcs_key) categories_response_json = get_wcs_json(wcs_site, 'api/categories/') if not categories_response_json or categories_response_json.get('err') == 1: # can not retrieve data, don't report cell as invalid return category_found = any( [c.get('slug') == category_slug for c in categories_response_json.get('data') or []] ) if not category_found: self.mark_as_invalid('wcs_category_not_found') return self.mark_as_valid() def get_cell_extra_context(self, context): extra_context = super().get_cell_extra_context(context) extra_context.update(WcsBlurpMixin.get_cell_extra_context(self, context)) extra_context['request_is_a_bot'] = is_bot(context.get('request')) if not self.category_reference: return extra_context extra_context['slug'] = self.category_reference.split(':')[-1] extra_context['title'] = self.cached_title extra_context['category_description'] = self.cached_description try: extra_context['forms'] = list(extra_context['forms'][self.wcs_site]['data']) except (KeyError, TypeError): # an error occured when getting the data extra_context['forms'] = [] # default sort is alphabetical, it's always done as this will serve as # secondary sort key (thanks to Python stable sort) extra_context['forms'] = sorted(extra_context['forms'], key=lambda x: x.get('title')) extra_context['more_forms'] = [] if self.ordering == 'popularity': extra_context['forms'] = sorted( extra_context['forms'], key=lambda x: x.get('count'), reverse=True ) elif self.ordering == 'manual': if self.manual_order: manual_order = self.manual_order.get('data') for form in extra_context['forms']: form_reference = '%s:%s' % (self.category_reference, form['slug']) try: form['order'] = manual_order.index(form_reference) except ValueError: form['order'] = 9999 extra_context['forms'] = sorted(extra_context['forms'], key=lambda x: x.get('order', 9999)) for formdef in extra_context['forms']: formdef['css_classes'] = get_formdef_css_classes(formdef) if self.limit: if len(extra_context['forms']) > self.limit: extra_context['more_forms'] = extra_context['forms'][self.limit :] extra_context['forms'] = extra_context['forms'][: self.limit] return extra_context def render_for_search(self): return '' def get_external_links_data(self): if not self.category_reference: return formdefs = self.get_data({'synchronous': True}) for site in formdefs.values(): for formdef in site.get('data', []): text = ' '.join([formdef.get('description', '')] + formdef.get('keywords', [])) yield {'url': formdef['url'], 'title': formdef['title'], 'text': text} @register_cell_class class WcsCareFormsCell(CategoriesAndWcsSiteValidityMixin, CategoriesFilteringMixin, WcsDataBaseCell): categories = JSONField(_('Categories'), blank=True, default=dict) custom_title = models.CharField(_('Custom Title'), max_length=150, blank=True) api_url = '/api/forms/?limit=10' variable_name = 'care_forms' default_template_name = 'combo/wcs/care_forms.html' cache_duration = models.PositiveIntegerField(_('Cache duration'), default=120, help_text=_('In seconds.')) user_dependant = True class Meta: verbose_name = _('Forms to process') def get_default_form_class(self): from .forms import WcsCareFormsCellForm return WcsCareFormsCellForm def get_manager_tabs(self): tabs = super().get_manager_tabs() tabs.insert( 1, { 'slug': 'advanced', 'name': _('Advanced'), 'fields': ['cache_duration'], }, ) return tabs def get_cell_extra_context(self, context): context = super().get_cell_extra_context(context) context['is_portal_agent'] = is_portal_agent() categories_filter = [] if self.categories: for category in self.categories.get('data', []): categories_filter.append(tuple(category.split(':'))) for wcs_site in context['care_forms']: if not context['care_forms'].get(wcs_site): continue context['care_forms'][wcs_site]['categories'] = [v for k, v in categories_filter if k == wcs_site] return context def save(self, *args, **kwargs): result = super().save(*args, **kwargs) self.check_validity() return result @register_cell_class class CategoriesCell(WcsDataBaseCell): api_url = '/api/categories/?full=on' variable_name = 'form_categories' default_template_name = 'combo/wcs/form_categories.html' cache_duration = 120 class Meta: verbose_name = _('Form Categories') def check_validity(self): if self.wcs_site and self.wcs_site not in get_wcs_services(): self.mark_as_invalid('wcs_site_not_found') return self.mark_as_valid() @classmethod def is_enabled(cls): return settings.CATEGORIES_CELL_ENABLED and is_wcs_enabled(cls) class CardMixin: invalid_reason_codes = invalid_reason_codes def is_relevant(self, context): return bool(self.carddef_reference) @property def wcs_site(self): return self.carddef_reference.split(':')[0] @property def card_slug(self): return self.carddef_reference.split(':')[1] @property def card_custom_view(self): try: return self.carddef_reference.split(':')[2] except IndexError: return None def get_additional_label(self): return escape(self.custom_title) or self.cached_title or None class LazyValue: sentinel = object() def __init__(self, getter): self.value = self.sentinel self.getter = getter def __call__(self): if self.value is self.sentinel: self.value = self.getter() return self.value @register_cell_class class WcsCardCell(CardMixin, CellBase): carddef_reference = models.CharField(_('Card Model'), max_length=150) related_card_path = models.CharField( _('Card(s) to display'), max_length=1000, blank=True, default='__all__' ) card_ids = models.CharField(_('Other Card Identifiers'), max_length=1000, blank=True) only_for_user = models.BooleanField(_('Limit to cards linked to the logged-in user'), default=False) without_user = models.BooleanField(_('Ignore the logged-in user'), default=False) limit = models.PositiveSmallIntegerField( _('Number of cards per page (default 10)'), null=True, blank=True ) custom_schema = JSONField(blank=True, default=dict) display_mode = models.CharField( _('Display mode'), max_length=10, default='card', choices=[ ('card', pgettext_lazy('card-display-mode', 'Card')), ('table', pgettext_lazy('card-display-mode', 'Table')), ], ) title_type = models.CharField( _('Title'), max_length=20, default='auto', blank=False, choices=[ ('auto', _('Default Title (Card Label)')), ('manual', _('Custom Title')), ('empty', _('No Title')), ], ) custom_title = models.CharField(_('Custom Title'), max_length=150, blank=True) cached_title = models.CharField(_('Title'), max_length=150) cached_json = JSONField(blank=True, default=dict) is_enabled = classmethod(is_wcs_enabled) session_required = True manager_appearance_template = 'combo/wcs/manager/card-cell-form-appearance.html' # cards will already be searchable using their respective search engines exclude_from_search = True class Meta: verbose_name = _('Card(s)') def get_card_ids(self, context): lazy_value = context.get(self.global_context_key) if lazy_value is not None: return lazy_value() return [] def save(self, *args, **kwargs): super().save(*args, **kwargs) if 'update_fields' in kwargs: # don't populate the cache return def populate_cache(): if self.carddef_reference: parts = self.carddef_reference.split(':') wcs_key, card_slug = parts[:2] wcs_site = get_wcs_services().get(wcs_key) card_schema = get_wcs_json(wcs_site, 'api/cards/%s/@schema' % card_slug, log_errors='warn') if not card_schema: # can not retrieve data, don't report cell as invalid self.mark_as_valid() return if card_schema.get('err') == 1: if card_schema.get('err_class') == 'Page not found': self.mark_as_invalid('wcs_card_not_found') else: self.mark_as_valid() return self.cached_title = card_schema['name'] self.cached_json = card_schema self.save(update_fields=['cached_title', 'cached_json']) self.mark_as_valid() else: self.mark_as_invalid('wcs_card_not_defined') populate_cache() def is_visible(self, request, **kwargs): user = getattr(request, 'user', None) if self.only_for_user and (not user or user.is_anonymous): return False return super().is_visible(request, **kwargs) def check_validity(self): if self.get_related_card_path(): relations = [r[0] for r in self.get_related_card_paths()] if self.get_related_card_path() not in relations: self.mark_as_invalid('wcs_card_relation_not_found') return self.mark_as_valid() @property def default_template_name(self): if self.display_mode == 'table': return 'combo/wcs/cards.html' return 'combo/wcs/card.html' def get_serialized_cell(self): serialized_cell = super().get_serialized_cell() custom_schema = serialized_cell['fields']['custom_schema'] for cell in custom_schema.get('cells') or []: if cell.get('page') not in ['', None]: try: page = Page.objects.get(pk=cell['page']) cell['page'] = page.natural_key()[0] except Page.DoesNotExist: cell['page'] = '' return serialized_cell @classmethod def prepare_serialized_data(cls, cell_data): if not cell_data['fields'].get('custom_schema'): return cell_data custom_schema = cell_data['fields']['custom_schema'] for cell in custom_schema.get('cells') or []: if cell.get('page'): cell['page'] = Page.get_page_ids_by_uuids().get(cell['page']) or '' return cell_data @property def global_context_key(self): return '%s-card-ids' % self.get_reference() def modify_global_context(self, context, request): if self.display_mode == 'table' and not context.get('synchronous'): # don't call wcs on page loading return if self.carddef_reference and self.global_context_key not in context: # self.resolve_card_ids(context, request) context[self.global_context_key] = LazyValue(lambda: self.resolve_card_ids(context, request)) def get_repeat_template(self, context): if self.display_mode == 'table': # don't repeat cell if table display mode return [] return len(self.get_card_ids(context)) def get_related_card_path(self): if self.related_card_path == '__all__': return '' return self.related_card_path def must_get_all(self): if self.related_card_path == '__all__': return True return False def get_cards_from_ids(self, card_ids, context, synchronous=False): # if check_user, get all cards from ids in context, with correct filter-user-uuid and without_user value # use custom view if requested api_url = '/api/cards/%s/list' % (self.card_slug) if self.card_custom_view: api_url = '/api/cards/%s/list/%s' % ( self.card_slug, self.card_custom_view, ) api_url += '?include-fields=on&include-submission=on&include-workflow=on' user = self.get_concerned_user(context) if self.only_for_user and user and not user.is_anonymous and user.get_name_id(): api_url += '&filter-user-uuid=%s' % user.get_name_id() if not self.must_get_all(): api_url += '&%s' % '&'.join(['filter-internal-id=%s' % cid for cid in card_ids]) if not synchronous: synchronous = bool(context.get('synchronous')) wcs_site = get_wcs_services().get(self.wcs_site) try: response = requests.get( api_url, remote_service=wcs_site, user=None if self.without_user else getattr(context.get('request'), 'user', None), without_user=self.without_user, cache_duration=5, raise_if_not_cached=not synchronous, log_errors=False, ) response.raise_for_status() except RequestException: if self.card_custom_view: # if there's a custom view consider the error is a 404 because # the card was not found in that view, and mark it so. context['card_not_found'] = True return [] if response.status_code == 200: return response.json().get('data') or [] return [] def filter_card_ids(self, card_ids, context): # check that all ids in context are available for user cards = self.get_cards_from_ids(card_ids, context, synchronous=True) return [c['id'] for c in cards if str(c['id']) in [str(i) for i in card_ids]] def get_card_data_from_ids(self, card_id, context): # get the correct card from all known cards for ids in context card_ids = self.get_card_ids(context) if not card_ids: return None if len(card_ids) == 1: # if only one id, do not use the list endpoint: # may be url can be cached and reused by cells with related return self.get_card_data(card_id=card_id, context=context) cards = self.get_cards_from_ids(card_ids, context) for card_data in cards: if str(card_data.get('id')) == str(card_id): return card_data return None def get_card_data( self, card_id, context, card_slug=Ellipsis, card_custom_view=Ellipsis, only_for_user=Ellipsis, without_user=Ellipsis, synchronous=False, ): if card_slug is Ellipsis: card_slug = self.card_slug if card_custom_view is Ellipsis: card_custom_view = self.card_custom_view if only_for_user is Ellipsis: only_for_user = self.only_for_user if without_user is Ellipsis: without_user = self.without_user api_url = '/api/cards/%s/%s/' % (card_slug, card_id) if card_custom_view: api_url = '/api/cards/%s/%s/%s/' % ( card_slug, card_custom_view, card_id, ) api_url += ( '?include-files-content=off&include-evolution=off&include-roles=off&include-workflow-data=off' ) user = self.get_concerned_user(context) if only_for_user and user and not user.is_anonymous and user.get_name_id(): api_url += '&filter-user-uuid=%s' % user.get_name_id() if not synchronous: synchronous = bool(context.get('synchronous')) wcs_site = get_wcs_services().get(self.wcs_site) try: response = requests.get( api_url, remote_service=wcs_site, user=None if without_user else getattr(context.get('request'), 'user', None), without_user=without_user, cache_duration=5, raise_if_not_cached=not synchronous, log_errors=False, ) response.raise_for_status() except RequestException: if card_custom_view: # if there's a custom view consider the error is a 404 because # the card was not found in that view, and mark it so. context['card_not_found'] = True return {} if response.status_code == 200: return response.json() return {} def get_card_schema(self, card_slug): wcs_site = get_wcs_services().get(self.wcs_site) card_schema = get_wcs_json(wcs_site, 'api/cards/%s/@schema' % card_slug, log_errors='warn') if not card_schema: return None if card_schema.get('err') == 1: return None return card_schema def get_related_card_paths(self): if not self.carddef_reference: return [] def iter_relations(relations, path, labels, source_label, carddefs_already_seen): carddefs_already_seen = carddefs_already_seen[:] for relation in relations: new_path = '%s/%s%s' % ( path, 'reverse:' if relation['reverse'] else '', relation['varname'], ) new_labels = copy.deepcopy(labels) new_labels.append('"%s"' % (relation.get('label') or relation['varname'])) if relation['reverse']: label = _('Linked cards%s: %s (reverse relation)') % ( source_label, ' -> '.join(reversed(new_labels)), ) else: if relation['type'] == 'items': label = _('Linked cards%s: %s') % (source_label, ' -> '.join(new_labels)) else: label = _('Linked card%s: %s') % (source_label, ' -> '.join(new_labels)) if relation['obj'] == 'carddef:%s' % self.card_slug: # target carddef found yield (new_path, label) if not relation['reverse'] and relation['type'] in ['item', 'computed']: # relation is not multiple, continue to search for matching relations new_card_slug = relation['obj'][8:] # remove 'carddef:' new_card_schema = self.get_card_schema(new_card_slug) if new_card_slug not in carddefs_already_seen and new_card_schema: carddefs_already_seen.append(new_card_slug) yield from iter_relations( relations=new_card_schema.get('relations') or [], path=new_path, labels=new_labels, source_label=source_label, carddefs_already_seen=carddefs_already_seen, ) # get cells with explicit ids results = [] cells = WcsCardCell.get_cells_with_explicit_ids_by_page().get(self.page_id) or [] cells = [c for c in cells if c.pk != self.pk] many_cells = len(cells) > 1 for cell in cells: # follow relations results += list( iter_relations( relations=cell.cached_json.get('relations') or [], path=cell.slug, labels=[], source_label=' (%s)' % (_('From cell %s') % cell.slug) if many_cells else '', carddefs_already_seen=[self.card_slug], ) ) return results @classmethod @utils.cache_during_request def get_cells_with_explicit_ids_by_page(cls): result = collections.defaultdict(list) cells = ( cls.objects.exclude(slug='') # no slug .filter(related_card_path='') # no explicit ids .exclude(card_ids__contains=',') # multiple ids, can not follow relations ).order_by('order') for cell in cells: result[cell.page_id].append(cell) return result def get_card_ids_from_related(self, context, request): def get_relation(relations, varname, reverse): for relation in relations: if relation['reverse'] == reverse and relation['varname'] == varname: if reverse and relation['obj'][8:] != self.card_slug: # if reverse, it's the last part; check it's the correct card model continue return relation def follow_data(card_data, relations, varname, parts): reverse = varname.startswith('reverse:') if reverse: varname = varname[8:] # remove 'reverse:' relation = get_relation(relations, varname, reverse) if not relation: # not found - stop return [] card_slug = relation['obj'][8:] # remove 'carddef:' if not parts and card_slug != self.card_slug: # last part, but wrong card model return [] if reverse: # reverse relation: always multiple if not parts: # last part - get ids and stop ids = self.get_card_ids_from_template( '{{ cards|objects:"%s"|filter_by:"%s"|filter_value:"%s"|getlist:"id"|join:"," }}' % (card_slug, varname, card_data['id']), context, request, ) return ids # multiple relation, but still parts to follow - stop return [] # direct relation if not parts: # last part - stop raw_value = None if '%s_raw' % varname in card_data['fields']: raw_value = card_data['fields']['%s_raw' % varname] else: # may be a fields block ? varname_parts = varname.split('_') for i in range(len(varname_parts)): block_varname = '%s_raw' % '_'.join(varname_parts[: i + 1]) if block_varname in card_data['fields']: block_data = card_data['fields'][block_varname] if not block_data: continue field_varname = '%s_raw' % '_'.join(varname_parts[i + 1 :]) values = [v.get(field_varname) for v in block_data] values = [v for v in values if v] if values: return values if not raw_value: return [] if not isinstance(raw_value, list): # item or computed return [raw_value] # items return raw_value if relation['type'] == 'items': # multiple relation, but still parts to follow - stop return [] # single relation, get card_data and follow next part if not card_data['fields'].get('%s_raw' % varname): # field not found or empty return [] # get schema card_schema = self.get_card_schema(card_slug) if not card_schema: # card schema not found return [] # and data next_card_data = self.get_card_data( card_id=card_data['fields']['%s_raw' % varname], context=context, card_slug=card_slug, card_custom_view=None, only_for_user=False, without_user=False, synchronous=True, ) if not next_card_data: # card data not found return [] # continue return follow_data( card_data=next_card_data, relations=card_schema.get('relations') or [], varname=parts[0], parts=parts[1:], ) first_cell_slug = self.get_related_card_path().split('/', maxsplit=1)[0] try: first_cell = WcsCardCell.objects.get(page=self.page_id, slug=first_cell_slug) except (WcsCardCell.DoesNotExist, WcsCardCell.MultipleObjectsReturned): return [] if first_cell.related_card_path: # no explicit ids return [] if ',' in first_cell.card_ids: # multiple ids, can not follow relations return [] first_cell.repeat_index = 0 card_id = first_cell.get_card_id(context) if not card_id: # no card id found return [] card_data = self.get_card_data( card_id=card_id, context=context, card_slug=first_cell.card_slug, card_custom_view=first_cell.card_custom_view, only_for_user=first_cell.only_for_user, without_user=first_cell.without_user, synchronous=True, ) if not card_data: # card data not found return [] parts = self.get_related_card_path().split('/')[1:] return follow_data( card_data=card_data, relations=first_cell.cached_json.get('relations') or [], varname=parts[0], parts=parts[1:], ) def get_card_ids_from_template(self, template, original_context, request): try: context = RequestContext(request) context.push(original_context) ids = Template(template).render(context).split(',') return [i.strip() for i in ids if i.strip()] except (VariableDoesNotExist, TemplateSyntaxError): return [] def resolve_card_ids(self, original_context, request): if not self.carddef_reference: # not configured return [] if self.get_related_card_path(): # look at other cells to get related ids card_ids = self.get_card_ids_from_related(original_context, request) if card_ids == []: return [] elif card_ids: # check that ids from related are available for user # (use only_for_user and without_user flags) if len(card_ids) == 1: # if only one id, do not use the list endpoint: # may be url can be cached and reused by cells with related return ( card_ids if self.get_card_data(card_id=card_ids[0], context=original_context, synchronous=True) else [] ) return self.filter_card_ids(card_ids, original_context) if self.must_get_all(): if self.display_mode == 'table': # don't call wcs if table mode with all cards return [] # get all cards return [c['id'] for c in self.get_cards_from_ids([], original_context, synchronous=True)] if self.card_ids: # card ids template is defined return self.get_card_ids_from_template(self.card_ids, original_context, request) # get card id from page's sub slug (check in context) card_id = '%s_id' % self.card_slug if original_context.get(card_id): return [original_context[card_id]] # nothing found in context return [] def get_card_id(self, context): repeat_index = getattr(self, 'repeat_index', context.get('repeat_index')) or 0 if repeat_index is not None: try: return self.get_card_ids(context)[repeat_index] except (IndexError, TypeError): return None def get_extra_manager_context(self): extra_context = super().get_extra_manager_context() if self.cached_json: extra_context['card_schema'] = self.cached_json extra_context['card_schema_id'] = 'cell-%s-card-schema-%s' % (self.pk, self.carddef_reference) return extra_context @property def css_class_names(self): return '%s card card-%s %s' % (super().css_class_names, self.display_mode, self.get_reference()) def get_matching_pages(self, order=True): return get_matching_pages_from_card_slug(self.card_slug, order=order) def get_cell_extra_context(self, context): extra_context = super().get_cell_extra_context(context) if self.title_type in ['auto', 'manual']: # card mode: default value used if card is not found extra_context['title'] = self.cached_title extra_context['fields_by_varnames'] = { i['varname']: i for i in (self.cached_json.get('fields') or []) if i.get('varname') } # add fake user fields extra_context['fields_by_varnames'].update( { 'user:%s' % i['varname']: i for i in (self.cached_json.get('user', {}).get('fields') or []) if i.get('varname') } ) return getattr(self, 'get_cell_extra_context_%s_mode' % self.display_mode)(context, extra_context) def complete_card_data(self, card_data, custom_context): def render_template(item, template_key, template_context, target_key, target_context): if not item.get(template_key): return try: target_context[target_key][item[template_key]] = Template(item[template_key]).render( template_context ) except (VariableDoesNotExist, TemplateSyntaxError): return card_data['custom_fields'] = {} card_data['urls'] = {} if self.custom_schema: for item in self.get_custom_schema().get('cells') or []: if item.get('varname') not in ['@custom@', '@link@']: continue if not item.get('template'): continue render_template( item=item, template_key='template', template_context=custom_context, target_key='custom_fields', target_context=card_data, ) if item['varname'] == '@link@': if item.get('page'): if item['page'] in card_data['urls']: pass try: target = Page.objects.get(pk=item['page']) card_data['urls'][item['page']] = '%s%s/' % ( target.get_online_url(), card_data['id'], ) except Page.DoesNotExist: pass else: render_template( item=item, template_key='url_template', template_context=custom_context, target_key='urls', target_context=card_data, ) # add user fields card_data['fields'] = card_data.get('fields', {}) card_data['fields'].update( {('user:%s' % k): v for k, v in card_data.get('user', {}).items() if k not in ['NameId', 'id']} ) def get_cell_extra_context_table_mode(self, context, extra_context): if not context.get('synchronous'): raise NothingInCacheException() extra_context['paginate_by'] = self.limit or 10 if self.title_type == 'manual': extra_context['title'] = self.custom_title or extra_context['title'] if not self.carddef_reference: # not configured return extra_context matching_pages = self.get_matching_pages(order=False) if matching_pages: card_page = matching_pages[0] extra_context['card_page_base_url'] = card_page.get_online_url() card_ids = self.get_card_ids(context) if not card_ids and self.related_card_path != '__all__': extra_context['card_objects'] = [] else: extra_context['card_objects'] = self.get_cards_from_ids(card_ids, context) custom_context = Context(extra_context, autoescape=False) custom_context.update(context) for card_data in extra_context['card_objects']: custom_context['card'] = card_data self.complete_card_data(card_data, custom_context) return extra_context def get_cell_extra_context_card_mode(self, context, extra_context): extra_context['schema'] = self.cached_json card_id = self.get_card_id(context) if not card_id: if self.card_ids or self.related_card_path: # all cards, template or related_card_path defined, but result is empty or None extra_context['card_not_found'] = True return extra_context card_data = self.get_card_data_from_ids(card_id, context) if not card_data: return extra_context extra_context['card'] = card_data custom_context = Context(extra_context, autoescape=False) custom_context.update(context) repeat_index = getattr(self, 'repeat_index', context.get('repeat_index')) or 0 custom_context['repeat_index'] = repeat_index if self.title_type == 'manual': try: extra_context['title'] = Template(self.custom_title).render(custom_context) except (VariableDoesNotExist, TemplateSyntaxError): extra_context['title'] = '' # auto title or custom_title gives an empty title, use default value + card text if self.title_type == 'auto' or self.title_type == 'manual' and not extra_context['title']: extra_context['title'] = '%s - %s' % (self.cached_title, extra_context['card'].get('text')) self.complete_card_data(extra_context['card'], custom_context) return extra_context def get_default_form_class(self): from .forms import WcsCardCellForm return WcsCardCellForm def get_appearance_fields(self): return ['title_type', 'custom_title'] def get_appearance_form_class(self): from .forms import WcsCardCellAppearanceBaseForm return super().get_appearance_form_class(base_options_form_class=WcsCardCellAppearanceBaseForm) def get_manager_tabs(self): from .forms import WcsCardCellDisplayForm tabs = super().get_manager_tabs() tabs.append( { 'slug': 'display', 'name': _('Display'), 'template': 'combo/wcs/manager/card-cell-form-display.html', 'form': WcsCardCellDisplayForm, }, ) return tabs def get_custom_schema(self): custom_schema = copy.deepcopy(self.custom_schema or {}) if self.display_mode == 'table': # missing default values if custom_schema.get('cells') and not custom_schema.get('grid_headers'): custom_schema['grid_headers'] = False # clean values from card mode for cell in custom_schema.get('cells') or []: if cell.get('varname') not in ['@custom@', '@link@']: if cell.get('empty_value') in ['@skip@', '@empty@']: cell['empty_value'] = '' return custom_schema # missing default values if custom_schema.get('cells') and not custom_schema.get('grid_class'): custom_schema['grid_class'] = 'fx-grid--auto' for cell in custom_schema.get('cells') or []: # migrate old formats if cell.get('varname') == '@custom@': if cell.get('display_mode') == 'value': cell['display_mode'] = 'text' elif not cell.get('varname') == '@link@': if not cell.get('field_content'): if cell.get('display_mode') == 'title': cell['field_content'] = 'value' else: cell['field_content'] = cell.get('display_mode') or 'label-and-value' if cell.get('display_mode') in ['label', 'value', 'label-and-value', None]: cell['display_mode'] = 'text' # clean values from table mode if not cell.get('empty_value'): cell['empty_value'] = '@empty@' return custom_schema def get_asset_slot_key(self, key): # for legacy if self.display_mode == 'table': return 'cell:wcs_wcscardscell:%s:%s' % (key, self.get_slug_for_asset()) return 'cell:wcs_wcscardinfoscell:%s:%s' % (key, self.get_slug_for_asset()) def get_asset_slot_templates(self): # for legacy if self.display_mode == 'table': return settings.COMBO_CELL_ASSET_SLOTS.get('wcs_wcscardscell') return settings.COMBO_CELL_ASSET_SLOTS.get('wcs_wcscardinfoscell') @register_cell_class class TrackingCodeInputCell(CellBase): is_enabled = classmethod(is_wcs_enabled) wcs_site = models.CharField(_('Site'), max_length=50, blank=True) default_template_name = 'combo/wcs/tracking_code_input.html' class Meta: verbose_name = _('Tracking Code Input') def check_validity(self): if self.wcs_site and self.wcs_site not in get_wcs_services(): self.mark_as_invalid('wcs_site_not_found') return self.mark_as_valid() def get_default_form_class(self): if len(get_wcs_services()) == 1: return None combo_wcs_sites = [('', _('All Sites'))] + [ (x, y.get('title')) for x, y in get_wcs_services().items() ] return model_forms.modelform_factory( self.__class__, fields=['wcs_site'], widgets={'wcs_site': Select(choices=combo_wcs_sites)} ) def get_cell_extra_context(self, context): extra_context = super().get_cell_extra_context(context) if not self.wcs_site: self.wcs_site = list(get_wcs_services().keys())[0] extra_context['url'] = (get_wcs_services().get(self.wcs_site) or {}).get('url') return extra_context @register_cell_class class BackofficeSubmissionCell(CategoriesAndWcsSiteValidityMixin, CategoriesFilteringMixin, WcsDataBaseCell): api_url = '/api/formdefs/?backoffice-submission=on' variable_name = 'all_formdefs' default_template_name = 'combo/wcs/backoffice_submission.html' cache_duration = 120 user_dependant = True categories = JSONField(_('Categories'), blank=True, default=dict) class Meta: verbose_name = _('Backoffice Submission') class Media: js = ( '/jsi18n', 'js/combo.submission.js', ) def get_default_form_class(self): from .forms import BackofficeSubmissionCellForm return BackofficeSubmissionCellForm def get_cell_extra_context(self, context): context = super().get_cell_extra_context(context) all_formdefs = context.pop('all_formdefs') formdefs = {} # add a fake category where it's missing for key, site_formdefs in all_formdefs.items(): if not site_formdefs.get('data'): continue formdefs[key] = site_formdefs for formdef in site_formdefs['data']: if 'category' not in formdef: formdef['category'] = _('Misc') context['all_formdefs'] = formdefs return context