diff --git a/rdmo/conditions/models.py b/rdmo/conditions/models.py index e0bce8f94b..e96926c1b7 100644 --- a/rdmo/conditions/models.py +++ b/rdmo/conditions/models.py @@ -116,6 +116,63 @@ def is_locked(self): return self.locked def resolve(self, values, set_prefix=None, set_index=None): + """ + Resolves the condition and returns a detailed result. + + Returns: + dict: { + 'result': bool, + 'reason': str, + 'trigger_value': Any, + 'trigger_question_url': str + } + """ + source_values = self._filter_values(values, set_prefix, set_index) + + if not source_values and set_prefix: + set_prefix, set_index = self._get_higher_level_prefix(set_prefix) + return self.resolve(values, set_prefix, set_index) + + resolver_map = { + self.RELATION_EQUAL: self._resolve_equal, + self.RELATION_NOT_EQUAL: self._resolve_not_equal, + self.RELATION_CONTAINS: self._resolve_contains, + self.RELATION_GREATER_THAN: self._resolve_greater_than, + self.RELATION_GREATER_THAN_EQUAL: self._resolve_greater_than_equal, + self.RELATION_LESSER_THAN: self._resolve_lesser_than, + self.RELATION_LESSER_THAN_EQUAL: self._resolve_lesser_than_equal, + self.RELATION_EMPTY: self._resolve_empty, + self.RELATION_NOT_EMPTY: self._resolve_not_empty, + } + + resolver = resolver_map.get(self.relation) + if not resolver: + return {'result': False, 'reason': 'Unknown relation type', 'trigger_value': None, + 'trigger_question_url': None} + + result = resolver(source_values) + + if result['result']: + question_url = self._get_question_url(source_values) + result['trigger_question_url'] = question_url + + return result + + def _get_question_url(self, values): + """ + Finds the question associated with the resolved value. + """ + for value in values: + # Find the first question matching the attribute in the catalog + question = next( + (q for q in value.project.catalog.questions if q.attribute == self.source), + None + ) + if question: + return question.get_absolute_url(value) # Pass the value to `get_absolute_url` + return None + + def _filter_values(self, values, set_prefix, set_index): source_values = filter(lambda value: value.attribute == self.source, values) if set_prefix is not None: @@ -126,114 +183,125 @@ def resolve(self, values, set_prefix=None, set_index=None): value.set_index == int(set_index) or value.set_collection is False ), source_values) - source_values = list(source_values) - if not source_values: - if set_prefix: - # try one level higher - rpartition = set_prefix.rpartition('|') - set_prefix, set_index = rpartition[0], int(rpartition[2]) - return self.resolve(values, set_prefix, set_index) - - if self.relation == self.RELATION_EQUAL: - return self._resolve_equal(source_values) - - elif self.relation == self.RELATION_NOT_EQUAL: - return not self._resolve_equal(source_values) - - elif self.relation == self.RELATION_CONTAINS: - return self._resolve_contains(source_values) - - elif self.relation == self.RELATION_GREATER_THAN: - return self._resolve_greater_than(source_values) - - elif self.relation == self.RELATION_GREATER_THAN_EQUAL: - return self._resolve_greater_than_equal(source_values) - - elif self.relation == self.RELATION_LESSER_THAN: - return self._resolve_lesser_than(source_values) + return list(source_values) - elif self.relation == self.RELATION_LESSER_THAN_EQUAL: - return self._resolve_lesser_than_equal(source_values) - - elif self.relation == self.RELATION_EMPTY: - return not self._resolve_not_empty(source_values) - - elif self.relation == self.RELATION_NOT_EMPTY: - return self._resolve_not_empty(source_values) - - else: - return False + def _get_higher_level_prefix(self, set_prefix): + rpartition = set_prefix.rpartition('|') + return rpartition[0], int(rpartition[2]) def _resolve_equal(self, values): - results = [] - for value in values: - if self.target_option: - results.append(value.option == self.target_option) - else: - results.append(value.text == self.target_text) - - return True in results + if self.target_option and value.option == self.target_option: + return { + 'result': True, + 'reason': f"Value matches target option {self.target_option}.", + 'trigger_value': value.option + } + elif not self.target_option and value.text == self.target_text: + return { + 'result': True, + 'reason': f"Value matches target text '{self.target_text}'.", + 'trigger_value': value.text + } + return {'result': False, 'reason': 'No matching value found.', 'trigger_value': None} + + def _resolve_not_equal(self, values): + for value in values: + if self.target_option and value.option != self.target_option: + return { + 'result': True, + 'reason': f"Value does not match target option {self.target_option}.", + 'trigger_value': value.option + } + elif not self.target_option and value.text != self.target_text: + return { + 'result': True, + 'reason': f"Value does not match target text '{self.target_text}'.", + 'trigger_value': value.text + } + return {'result': False, 'reason': 'All values match the condition.', 'trigger_value': None} def _resolve_contains(self, values): - results = [] - for value in values: - results.append(self.target_text in value.text) - - return True in results + if self.target_text in value.text: + return { + 'result': True, + 'reason': f"Value contains target text '{self.target_text}'.", + 'trigger_value': value.text + } + return {'result': False, 'reason': 'No value contains the target text.', 'trigger_value': None} def _resolve_greater_than(self, values): - for value in values: try: if float(value.text) > float(self.target_text): - return True + return { + 'result': True, + 'reason': f"Value {value.text} is greater than target {self.target_text}.", + 'trigger_value': value.text + } except ValueError: - pass - - return False + continue + return {'result': False, 'reason': 'No value is greater than the target.', 'trigger_value': None} def _resolve_greater_than_equal(self, values): - for value in values: try: if float(value.text) >= float(self.target_text): - return True + return { + 'result': True, + 'reason': f"Value {value.text} is greater than or equal to target {self.target_text}.", + 'trigger_value': value.text + } except ValueError: - pass - - return False + continue + return {'result': False, 'reason': 'No value is greater than or equal to the target.', 'trigger_value': None} def _resolve_lesser_than(self, values): - for value in values: try: if float(value.text) < float(self.target_text): - return True + return { + 'result': True, + 'reason': f"Value {value.text} is less than target {self.target_text}.", + 'trigger_value': value.text + } except ValueError: - pass - - return False + continue + return {'result': False, 'reason': 'No value is less than the target.', 'trigger_value': None} def _resolve_lesser_than_equal(self, values): - for value in values: try: if float(value.text) <= float(self.target_text): - return True + return { + 'result': True, + 'reason': f"Value {value.text} is less than or equal to target {self.target_text}.", + 'trigger_value': value.text + } except ValueError: - pass + continue + return {'result': False, 'reason': 'No value is less than or equal to the target.', 'trigger_value': None} - return False + def _resolve_empty(self, values): + for value in values: + if not value.text and not value.option: + return { + 'result': True, + 'reason': "Value is empty.", + 'trigger_value': None + } + return {'result': False, 'reason': 'All values are non-empty.', 'trigger_value': None} def _resolve_not_empty(self, values): - for value in values: if bool(value.text) or bool(value.option): - return True - - return False + return { + 'result': True, + 'reason': "Value is not empty.", + 'trigger_value': value.text or value.option + } + return {'result': False, 'reason': 'All values are empty.', 'trigger_value': None} @classmethod def build_uri(cls, uri_prefix, uri_path): diff --git a/rdmo/projects/models/issue.py b/rdmo/projects/models/issue.py index 3e4f574191..7908910cdc 100644 --- a/rdmo/projects/models/issue.py +++ b/rdmo/projects/models/issue.py @@ -51,8 +51,7 @@ def get_absolute_url(self): def resolve(self, values): for condition in self.task.conditions.all(): - if condition.resolve(values): - return True + return condition.resolve(values) @property def dates(self): diff --git a/rdmo/projects/templates/projects/issue_detail.html b/rdmo/projects/templates/projects/issue_detail.html index 92abba4c88..d6bdba3772 100644 --- a/rdmo/projects/templates/projects/issue_detail.html +++ b/rdmo/projects/templates/projects/issue_detail.html @@ -47,13 +47,20 @@

{% trans 'Questions' %}

{{ question.text }}

{% endfor %} - {% for value in source.values %} -

{{ value.value_and_unit }}

+ {% for resolved_value in source.resolved_values %} +

+ Value: {{ resolved_value.value }}
+ Reason: {{ resolved_value.reason }}
+ {% if resolved_value.trigger_question_url %} + Triggered by: View Question + {% endif %} +

{% endfor %} {% endfor %} + {% if issue.resources.all %}

{% trans 'External resources for this task' %}

diff --git a/rdmo/projects/templates/projects/project_detail_issues.html b/rdmo/projects/templates/projects/project_detail_issues.html index 0e51a4257f..7586da2d99 100644 --- a/rdmo/projects/templates/projects/project_detail_issues.html +++ b/rdmo/projects/templates/projects/project_detail_issues.html @@ -28,41 +28,56 @@

{% trans 'Tasks' %}

{% endif %} - - {% for issue in issues %} - - - - {{ issue.task.title|markdown }} - - - {{ issue.task.text|markdown }} - - {% for dates in issue.dates %} - {% if dates|length > 1 %} -

{{ dates.0 | date:"DATE_FORMAT" }}
- {{ dates.1 | date:"DATE_FORMAT" }}

- {% else %} -

{{ dates.0 | date:"DATE_FORMAT" }}

- {% endif %} - {% endfor %} - - {{ issue.get_status_display }} - - - - - - {% if settings.PROJECT_SEND_ISSUE %} - - - {% endif %} - - + + {% for resolved_issue in issues %} + + + + {{ resolved_issue.issue.task.title|markdown }} + + + + {{ resolved_issue.issue.task.text|markdown }} + + + + {% for dates in resolved_issue.issue.dates %} + {% if dates|length > 1 %} +

{{ dates.0 | date:"DATE_FORMAT" }}
- {{ dates.1 | date:"DATE_FORMAT" }}

+ {% else %} +

{{ dates.0 | date:"DATE_FORMAT" }}

+ {% endif %} + {% endfor %} + + {{ resolved_issue.issue.get_status_display }} + + + + + + {% if settings.PROJECT_SEND_ISSUE %} + + + {% endif %} + + + {% endfor %} + + {% else %} diff --git a/rdmo/projects/views/issue.py b/rdmo/projects/views/issue.py index 9ec02de583..79e6e05568 100644 --- a/rdmo/projects/views/issue.py +++ b/rdmo/projects/views/issue.py @@ -38,10 +38,19 @@ def get_context_data(self, **kwargs): sources = [] for condition in conditions: + resolved_values = [] + for value in condition.source.values.filter(project=project, snapshot=None): + resolution = condition.resolve([value]) + resolved_values.append({ + 'value': value.value_and_unit, + 'reason': resolution['reason'], + 'trigger_question_url': resolution.get('trigger_question_url') + }) + sources.append({ 'source': condition.source, - 'questions': filter(lambda q: q.attribute == condition.source, project.catalog.questions), - 'values': condition.source.values.filter(project=project, snapshot=None) + 'questions': [q for q in project.catalog.questions if q.attribute == condition.source], + 'resolved_values': resolved_values }) kwargs['project'] = project diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index 9760fbd3b6..4eb1c052d8 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -46,32 +46,31 @@ class ProjectDetailView(ObjectPermissionMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - project = context['project'] + project = self.get_object() ancestors = project.get_ancestors(include_self=True) values = project.values.filter(snapshot=None).select_related('attribute', 'option') highest = Membership.objects.filter(project__in=ancestors, user_id=OuterRef('user_id')) \ - .order_by('-project__level') + .order_by('-project__level') memberships = Membership.objects.filter(project__in=ancestors) \ - .annotate(highest=Subquery(highest.values('project__level')[:1])) \ - .filter(highest=F('project__level')) \ - .select_related('user') + .annotate(highest=Subquery(highest.values('project__level')[:1])) \ + .filter(highest=F('project__level')) \ + .select_related('user') if settings.SOCIALACCOUNT: - # prefetch the users social account, if that relation exists memberships = memberships.prefetch_related('user__socialaccount_set') integrations = Integration.objects.filter(project__in=ancestors) context['catalogs'] = Catalog.objects.filter_current_site() \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user) + .filter_group(self.request.user) \ + .filter_availability(self.request.user) context['tasks_available'] = Task.objects.filter_current_site() \ - .filter_catalog(self.object.catalog) \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user).exists() + .filter_catalog(self.object.catalog) \ + .filter_group(self.request.user) \ + .filter_availability(self.request.user).exists() context['views_available'] = View.objects.filter_current_site() \ - .filter_catalog(self.object.catalog) \ - .filter_group(self.request.user) \ - .filter_availability(self.request.user).exists() + .filter_catalog(self.object.catalog) \ + .filter_group(self.request.user) \ + .filter_availability(self.request.user).exists() ancestors_import = [] for instance in ancestors.exclude(id=project.id): if self.request.user.has_perm('projects.view_project_object', instance): @@ -80,9 +79,26 @@ def get_context_data(self, **kwargs): context['memberships'] = memberships.order_by('user__last_name', '-project__level') context['integrations'] = integrations.order_by('provider_key', '-project__level') context['providers'] = get_plugins('PROJECT_ISSUE_PROVIDERS') - context['issues'] = [ - issue for issue in project.issues.order_by('-status', 'task__order', 'task__uri') if issue.resolve(values) - ] + + # Resolving issues and associated conditions + resolved_issues = [] + for issue in project.issues.order_by('-status', 'task__order', 'task__uri'): + issue_conditions = [] + for condition in issue.task.conditions.all(): + resolution = condition.resolve(values) + if resolution['result']: # If the condition is resolved + issue_conditions.append({ + 'condition': condition, + 'reason': resolution['reason'], + 'trigger_value': resolution['trigger_value'], + 'trigger_question_url': resolution.get('trigger_question_url') + }) + + if issue_conditions: # Add only if at least one condition is resolved + resolved_issues.append({'issue': issue, 'conditions': issue_conditions}) + + context['issues'] = resolved_issues + context['views'] = project.views.order_by('order', 'uri') context['snapshots'] = project.snapshots.all() context['invites'] = project.invites.all() diff --git a/rdmo/questions/models/question.py b/rdmo/questions/models/question.py index 6b184ee2e2..403f0395df 100644 --- a/rdmo/questions/models/question.py +++ b/rdmo/questions/models/question.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.sites.models import Site from django.db import models +from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -291,3 +292,16 @@ def build_uri(cls, uri_prefix, uri_path): if not uri_path: raise RuntimeError('uri_path is missing') return join_url(uri_prefix or settings.DEFAULT_URI_PREFIX, '/questions/', uri_path) + + def get_absolute_url(self, value): + """ + Build the URL to the page containing this question using the associated value's project. + """ + project = value.project # Access the project directly from the value + # Iterate over all pages in the catalog associated with the project + for page in project.catalog.pages: + # Check if the question is in the page's elements or descendants + if self in page.elements or self in page.descendants: + # Build and return the URL using the project ID and page ID + return reverse('v1-projects:project-page-detail', args=[project.id, page.id]) + return None # Return None if no relevant page is found