Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6fbf229
added RichTextField (Editor) field source_by to partner_field model
roman-stolar Nov 4, 2025
62e4c3f
configuration improvments
roman-stolar Nov 4, 2025
397bbdc
serialize source_by data to response
roman-stolar Nov 4, 2025
e23f923
displayed source_by information in Production Location Profile partne…
roman-stolar Nov 4, 2025
71d3ded
Merge commit '07e3039a9690f18bc531a9dd28e86accc0d597d4' into OSDEV-21…
roman-stolar Nov 4, 2025
268eb71
fix
roman-stolar Nov 4, 2025
3a47558
small issues fixes + fixed linter
roman-stolar Nov 4, 2025
c1d7043
Merge branch 'main' into OSDEV-2185-display-source-by
roman-stolar Nov 4, 2025
185344f
fixed sonarqube comment
roman-stolar Nov 4, 2025
c4fc76e
added serializer unit tests
roman-stolar Nov 5, 2025
d926f47
added tests for FacilityDetailsDetail component
roman-stolar Nov 5, 2025
4b7da95
updated release notes
roman-stolar Nov 5, 2025
6a0c198
fix linter
roman-stolar Nov 5, 2025
794ff67
Merge branch 'main' into OSDEV-2185-display-source-by
roman-stolar Nov 5, 2025
802e04f
fix
roman-stolar Nov 5, 2025
87c0400
Merge commit '794ff678619bd1a748aaed92a96bd52a7f35f896' into OSDEV-21…
roman-stolar Nov 5, 2025
616e2d5
Merge branch 'main' into OSDEV-2185-display-source-by
roman-stolar Nov 6, 2025
7ce7a7b
Merge branch 'main' into OSDEV-2185-display-source-by
roman-stolar Nov 10, 2025
0fef524
Merge branch 'main' into OSDEV-2185-display-source-by
roman-stolar Nov 11, 2025
508af65
fix conflict
roman-stolar Nov 11, 2025
26abcc5
updated release notes
roman-stolar Nov 11, 2025
04fff34
address Vlad comments
roman-stolar Nov 12, 2025
0d483a8
fix migration number in release notes
roman-stolar Nov 12, 2025
d1ce0a8
[OSDEV-2199] [Climate TRACE] Display partner field "label" and "unit"…
roman-stolar Nov 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@ All notable changes to this project will be documented in this file.

This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). The format is based on the `RELEASE-NOTES-TEMPLATE.md` file.

## Release 2.16.0

## Introduction
* Product name: Open Supply Hub
* Release date: November 15, 2025

#### Migrations
* 0184_add_source_by_to_partner_field.py - This migration adds a `source_by` RichTextField to the `PartnerField` model, allowing administrators to document the data source for each partner field using rich text formatting (bold, italic, links, lists). The field is optional and uses CKEditor for content editing.

### What's new
* [OSDEV-2185](https://opensupplyhub.atlassian.net/browse/OSDEV-2185) - Enhanced partner field display on production location profiles by adding a `source_by` field to the `PartnerField` model. This allows administrators to provide rich text descriptions of data sources. The source information is displayed on the facility details page below each partner field value, supporting HTML formatting for links, emphasis, and lists. Updated the facility index serializer to include `source_by` in the partner fields response only when the field contains content.

### Release instructions
* Ensure that the following commands are included in the `post_deployment` command:
* `migrate`
* `reindex_database`


## Release 2.15.0

## Introduction
Expand Down
18 changes: 16 additions & 2 deletions src/django/api/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json

from django import forms
from django.urls import path
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
Expand All @@ -14,6 +15,7 @@
from simple_history.admin import SimpleHistoryAdmin
from waffle.models import Flag, Sample, Switch
from waffle.admin import FlagAdmin, SampleAdmin, SwitchAdmin
from ckeditor.widgets import CKEditorWidget

from api import models

Expand Down Expand Up @@ -241,9 +243,21 @@ def get_ordering(self, request):
return ['name']


class PartnerFieldAdminForm(forms.ModelForm):
source_by = forms.CharField(
required=False,
widget=CKEditorWidget()
)

class Meta:
model = PartnerField
fields = ['name', 'type', 'unit', 'label', 'source_by']


class PartnerFieldAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'label', 'unit', 'created_at')
search_fields = ('name', 'type', 'label', 'unit')
form = PartnerFieldAdminForm
list_display = ('name', 'type', 'label', 'unit', 'source_by', 'created_at')
search_fields = ('name', 'type', 'label', 'unit', 'source_by')
readonly_fields = ('uuid', 'created_at', 'updated_at')


Expand Down
2 changes: 1 addition & 1 deletion src/django/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,4 @@ class APIV1MatchTypes:
# Use this for frontend compatibility.
JS_MAX_SAFE_INTEGER = 9007199254740991

PARTNER_FIELD_NAMES_LIST_KEY = 'partner_field_names_list'
PARTNER_FIELD_LIST_KEY = 'partner_field_list'
20 changes: 20 additions & 0 deletions src/django/api/migrations/0184_add_source_by_to_partner_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.17 on 2025-11-04 11:47

from django.db import migrations
import ckeditor.fields


class Migration(migrations.Migration):

dependencies = [
('api', '0183_add_claim_fields_for_new_process'),
]

operations = [
migrations.AddField(
model_name='partnerfield',
name='source_by',
field=ckeditor.fields.RichTextField(blank=True, null=True, config_name='default'),
),
]

17 changes: 14 additions & 3 deletions src/django/api/models/partner_field.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import uuid
from django.db import models
from django.core.cache import cache
from api.constants import PARTNER_FIELD_NAMES_LIST_KEY
from ckeditor.fields import RichTextField
from api.constants import PARTNER_FIELD_LIST_KEY


class PartnerField(models.Model):
Expand Down Expand Up @@ -49,6 +50,16 @@ class Meta:
blank=True,
help_text=('The partner field label.'))

source_by = RichTextField(
blank=True,
null=True,
config_name='default',
help_text=(
'Rich text field describing '
'the source of this partner field.'
)
)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand All @@ -57,9 +68,9 @@ def __str__(self):

def save(self, *args, **kwargs):
super().save(*args, **kwargs)
cache.delete(PARTNER_FIELD_NAMES_LIST_KEY)
cache.delete(PARTNER_FIELD_LIST_KEY)

def delete(self, *args, **kwargs):
result = super().delete(*args, **kwargs)
cache.delete(PARTNER_FIELD_NAMES_LIST_KEY)
cache.delete(PARTNER_FIELD_LIST_KEY)
return result
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def __init__(self,
self.fields: list = ['id', 'is_verified', 'value', 'created_at',
'updated_at', 'contributor_name',
'contributor_id', 'value_count', 'is_from_claim',
'field_name', 'verified_count']
'field_name', 'verified_count', 'source_by']
self.data: list = []

if exclude_fields:
Expand Down Expand Up @@ -50,6 +50,10 @@ def _serialize_extended_field_list(self) -> None:
elif field == 'verified_count':
serialized_extended_field[field] = \
self._get_verified_count(extended_field)
elif (field == 'source_by' and
self.context.get('source_by', None) is not None):
serialized_extended_field[field] = \
self.context.get('source_by')
else:
serialized_extended_field[field] = extended_field.get(
field)
Expand Down
25 changes: 14 additions & 11 deletions src/django/api/serializers/facility/facility_index_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)

from countries.lib.countries import COUNTRY_NAMES
from api.constants import PARTNER_FIELD_NAMES_LIST_KEY
from api.constants import PARTNER_FIELD_LIST_KEY
from ...models import Contributor
from ...models.facility.facility_index import FacilityIndex
from ...models.embed_config import EmbedConfig
Expand Down Expand Up @@ -95,14 +95,16 @@ def __get_request(self):
def __serialize_and_sort_partner_fields(
self,
grouped_fields: Dict[str, List[Dict[str, Any]]],
partner_field_names: List[str],
partner_fields: List[PartnerField],
user_can_see_detail: bool,
embed_mode_active: bool,
use_main_created_at: bool,
date_field_to_sort: str
) -> Dict[str, List[Dict[str, Any]]]:
grouped_data = {}
for field_name in partner_field_names:
for field in partner_fields:
field_name = field.name
source_by = field.source_by
fields = grouped_fields.get(field_name, [])
if not fields:
continue
Expand All @@ -113,6 +115,7 @@ def __serialize_and_sort_partner_fields(
context={
'user_can_see_detail': user_can_see_detail,
'embed_mode_active': embed_mode_active,
'source_by': source_by
},
exclude_fields=(
['created_at'] if not use_main_created_at else []
Expand Down Expand Up @@ -190,19 +193,19 @@ def __group_fields_by_name(
return grouped

@staticmethod
def __get_partner_field_names():
cached_names = cache.get(PARTNER_FIELD_NAMES_LIST_KEY)
def __get_partner_fields():
cached_names = cache.get(PARTNER_FIELD_LIST_KEY)

if cached_names is not None:
return cached_names

names = list(
PartnerField.objects.values_list("name", flat=True)
partner_fields = list(
PartnerField.objects.all()
)

cache.set(PARTNER_FIELD_NAMES_LIST_KEY, names, 600)
cache.set(PARTNER_FIELD_LIST_KEY, partner_fields, 60)

return names
return partner_fields

def get_location(self, facility):
return facility.location
Expand Down Expand Up @@ -429,11 +432,11 @@ def get_partner_fields(self, facility):

user_can_see_detail = can_user_see_detail(self)
embed_mode_active = is_embed_mode_active(self)
field_names = self.__get_partner_field_names()
partner_fields = self.__get_partner_fields()

return self.__serialize_and_sort_partner_fields(
grouped_fields,
field_names,
partner_fields,
user_can_see_detail,
embed_mode_active,
use_main_created_at,
Expand Down
81 changes: 81 additions & 0 deletions src/django/api/tests/test_facility_index_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,84 @@ def test_partner_fields_exist(self):
test_data_field[0]['value'],
{'raw_value': 'Transport Data'}
)

def test_partner_fields_includes_source_by(self):
self.partner_field_1.source_by = '<strong>Climate TRACE</strong> source'
self.partner_field_1.save()

extended_field = ExtendedField.objects.create(
facility=self.facility,
field_name='test_data_field',
value={'raw_value': 'Test Value'},
contributor=self.contrib_one
)

facility_index = FacilityIndex.objects.get(id=self.facility.id)
facility_index.extended_fields.append({
'id': extended_field.id,
'field_name': 'test_data_field',
'value': {'raw_value': 'Test Value'},
'contributor': {
'id': self.contrib_one.id,
'name': self.contrib_one.name,
'is_verified': self.contrib_one.is_verified,
},
'created_at': extended_field.created_at.isoformat(),
'updated_at': extended_field.updated_at.isoformat(),
'is_verified': False,
'facility_list_item_id': None,
'should_display_association': True,
'value_count': 1,
})
facility_index.save()
facility_index.refresh_from_db()

data = FacilityIndexSerializer(facility_index).data
partner_fields = data["properties"]["partner_fields"]
test_data_field = partner_fields['test_data_field']

self.assertEqual(len(test_data_field), 1)
self.assertIn('source_by', test_data_field[0])
self.assertEqual(
test_data_field[0]['source_by'],
'<strong>Climate TRACE</strong> source'
)

def test_partner_fields_source_by_is_none_when_not_set(self):
extended_field = ExtendedField.objects.create(
facility=self.facility,
field_name='test_data_field',
value={'raw_value': 'Test Value'},
contributor=self.contrib_one
)

facility_index = FacilityIndex.objects.get(id=self.facility.id)
facility_index.extended_fields.append({
'id': extended_field.id,
'field_name': 'test_data_field',
'value': {'raw_value': 'Test Value'},
'contributor': {
'id': self.contrib_one.id,
'name': self.contrib_one.name,
'is_verified': self.contrib_one.is_verified,
},
'created_at': extended_field.created_at.isoformat(),
'updated_at': extended_field.updated_at.isoformat(),
'is_verified': False,
'facility_list_item_id': None,
'should_display_association': True,
'value_count': 1,
})
facility_index.save()
facility_index.refresh_from_db()

data = FacilityIndexSerializer(facility_index).data
partner_fields = data["properties"]["partner_fields"]
test_data_field = partner_fields['test_data_field']

self.assertEqual(len(test_data_field), 1)
self.assertIn('source_by', test_data_field[0])
self.assertEqual(
test_data_field[0]['source_by'],
None
)
19 changes: 19 additions & 0 deletions src/django/oar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
'web',
'ecsmanage',
'django_bleach',
'ckeditor',
]

# For allauth
Expand Down Expand Up @@ -501,6 +502,24 @@
},
}

CKEDITOR_CONFIGS = {
'default': {
'toolbar': [
['Bold', 'Italic', 'Underline', 'Strike'],
['NumberedList', 'BulletedList'],
['Link', 'Unlink'],
['RemoveFormat', 'Source'],
],
'height': 250,
'width': '100%',
'removePlugins': 'uploadimage,uploadfile,image,flash,smiley',
'enterMode': 2,
'shiftEnterMode': 2,
'autoParagraph': False,
'fillEmptyBlocks': False,
}
}

# Application settings
MAX_UPLOADED_FILE_SIZE_IN_BYTES = 5242880
MAX_ATTACHMENT_SIZE_IN_BYTES = 5 * 1024 * 1024 # 5 MB
Expand Down
1 change: 1 addition & 0 deletions src/django/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ django-storages==1.13.1
django-waffle==2.5.0
django-watchman==1.3.0
drf-yasg==1.20.0
django-ckeditor==6.5.1
djangorestframework-gis==1.0.0
djangorestframework==3.13.1
flake8==4.0.1
Expand Down
Loading
Loading