Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
36 changes: 36 additions & 0 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@ 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 29, 2025

### What's new
* [OSDEV-2112](https://opensupplyhub.atlassian.net/browse/OSDEV-2112) - Moved "Recruitment Agency" (previously classified as a location type) under the "Office / HQ" location type as a processing type. Also introduced a new processing type, "Union Headquarters/Office", under the "Office / HQ" location type. This update affects both search and newly contributed data: from now on, "Union Headquarters/Office" and "Recruitment Agency" will appear under the "Office / HQ" location type when displayed in search dropdowns or shown on location profiles for **newly** added locations.

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


## Release 2.15.1

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

### Database changes

#### Migrations
* 0185_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.
* [OSDEV-2199](https://opensupplyhub.atlassian.net/browse/OSDEV-2199) - Added `unit` and `label` metadata from `PartnerField` to the serialized partner fields payload. Production Location detail pages now render the `unit` inline with field values and display custom partner field `label`.

### 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 @@ -15,6 +16,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 @@ -242,9 +244,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/0185_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', '0184_remove_facilityclaim_facility_type_choices'),
]

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,8 @@ 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',
'unit', 'label']
self.data: list = []

if exclude_fields:
Expand All @@ -28,31 +29,34 @@ def __init__(self,
self._serialize_extended_field_list()

def _serialize_extended_field_list(self) -> None:
field_serializers = {
'created_at': self._get_created_at,
'updated_at': self._get_updated_at,
'contributor_name': self._get_contributor_name,
'contributor_id': self._get_contributor_id,
'is_from_claim': self._get_is_from_claim,
'verified_count': self._get_verified_count,
}
context_overrides = {'source_by', 'unit', 'label'}

for extended_field in self.extended_field_list:
serialized_extended_field = {}

for field in self.fields:
if field == 'created_at':
serialized_extended_field[field] = \
self._get_created_at(extended_field)
elif field == 'updated_at':
serialized_extended_field[field] = \
self._get_updated_at(extended_field)
elif field == 'contributor_name':
serialized_extended_field[field] = \
self._get_contributor_name(extended_field)
elif field == 'contributor_id':
serialized_extended_field[field] = \
self._get_contributor_id(extended_field)
elif field == 'is_from_claim':
serialized_extended_field[field] = \
self._get_is_from_claim(extended_field)
elif field == 'verified_count':
if field in field_serializers:
serialized_extended_field[field] = \
self._get_verified_count(extended_field)
else:
serialized_extended_field[field] = extended_field.get(
field)
field_serializers[field](
extended_field
)
continue

if field in context_overrides:
context_value = self.context.get(field)
if context_value is not None:
serialized_extended_field[field] = context_value
continue

serialized_extended_field[field] = extended_field.get(field)

self.data.append(serialized_extended_field)

Expand Down
29 changes: 18 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,18 @@ 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
unit = field.unit
label = field.label
fields = grouped_fields.get(field_name, [])
if not fields:
continue
Expand All @@ -113,6 +117,9 @@ 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,
'unit': unit,
'label': label
},
exclude_fields=(
['created_at'] if not use_main_created_at else []
Expand Down Expand Up @@ -190,19 +197,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_cached_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 +436,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_cached_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
Loading