Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* Product name: Open Supply Hub
* Release date: September 20, 2025

### Database changes

#### Migrations
* 0177_add_partner_fields_to_contributor.py - This migration introduces a new table called `Partner Field`, enhanced `Contributor` model with `partner_fields` relationship `ManyToMany`.

### Code/API changes
* [OSDEV-2137](https://opensupplyhub.atlassian.net/browse/OSDEV-2137) - Switched to a custom, page-compatible keyset for the `/facilities-downloads` endpoint, enabling more efficient, cursor-based pagination and improved download performance and compatibility.
* [OSDEV-2089](https://opensupplyhub.atlassian.net/browse/OSDEV-2089) - Added `geocoded_location_type` and `geocoded_address` fields to GET `/api/v1/production-locations/` and GET `/api/v1/production-locations/{os_id}/` endpoints.
* [OSDEV-2068](https://opensupplyhub.atlassian.net/browse/OSDEV-2068) - Enabled users to download their own data without impacting free & purchased data-download allowances. Introduced `is_same_contributor` field in the GET `/api/facilities-downloads` response.

### What's new
* [OSDEV-2164](https://opensupplyhub.atlassian.net/browse/OSDEV-2164) - Added search functionality for user email and contributor name in the Facility Download Limits admin page.
* [OSDEV-2066](https://opensupplyhub.atlassian.net/browse/OSDEV-2066) - Added permission system to control which partner data fields contributors can submit.
Created new `PartnerField` model for categorizing partner-specific fields. Enhanced `Contributor` model with `partner_fields` relationship.

### Release instructions
* Ensure that the following commands are included in the `post_deployment` command:
Expand Down
9 changes: 9 additions & 0 deletions src/django/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from api.models.sector_group import SectorGroup
from api.models.partner_field import PartnerField
from simple_history.admin import SimpleHistoryAdmin
from waffle.models import Flag, Sample, Switch
from waffle.admin import FlagAdmin, SampleAdmin, SwitchAdmin
Expand Down Expand Up @@ -113,6 +114,7 @@ def pretty_results(self, instance):
class ContributorAdmin(SimpleHistoryAdmin):
history_list_display = ('is_verified', 'verification_notes')
search_fields = ('name', 'admin__email')
filter_horizontal = ('partner_fields',)

def get_ordering(self, request):
return ['name']
Expand Down Expand Up @@ -239,6 +241,12 @@ def get_ordering(self, request):
return ['name']


class PartnerFieldAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'created_at')
search_fields = ('name', 'type')
readonly_fields = ('uuid', 'created_at', 'updated_at')


admin_site.register(models.Version)
admin_site.register(models.User, OarUserAdmin)
admin_site.register(models.Contributor, ContributorAdmin)
Expand All @@ -261,3 +269,4 @@ def get_ordering(self, request):
admin_site.register(models.Sector, SectorAdmin)
admin_site.register(SectorGroup, SectorGroupAdmin)
admin_site.register(models.FacilityDownloadLimit, FacilityDownloadLimitAdmin)
admin_site.register(PartnerField, PartnerFieldAdmin)
3 changes: 2 additions & 1 deletion src/django/api/management/commands/sync_databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ class DatabaseSynchronizer:
'embed_config',
'embed_level',
'created_at',
'updated_at'
'updated_at',
'partner_fields'
]
},
'FacilityList': {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from django.db import migrations, models
import uuid

class Migration(migrations.Migration):
"""
Migration to create PartnerField model and add partner_fields ManyToManyField to Contributor model.
"""

dependencies = [
('api', '0176_introduce_enable_dromo_uploading_switch'),
]

operations = [
migrations.CreateModel(
name='PartnerField',
fields=[
('name', models.CharField(help_text='The partner field name.', max_length=200, primary_key=True, serialize=False)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for the partner field.', unique=True)),
('type', models.CharField(help_text='The partner field type.', max_length=200, blank=False, null=False, choices=[('int','int'),('float','float'),('string','string'),('object','object')])),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'partner field',
},
),

migrations.AddField(
model_name='contributor',
name='partner_fields',
field=models.ManyToManyField(
blank=True,
help_text='Partner fields that this contributor can access',
to='api.partnerfield'
),
),
]
8 changes: 7 additions & 1 deletion src/django/api/models/contributor/contributor.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,19 @@ class Contributor(models.Model):
max_length=200,
help_text="The environment value where instance running"
)
partner_fields = models.ManyToManyField(
'PartnerField',
blank=True,
null=True,
help_text='Partner fields that this contributor can access'
)

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

objects = ContributorManager.as_manager()
history = HistoricalRecords(
excluded_fields=['uuid', 'origin_source']
excluded_fields=['uuid', 'origin_source', 'partner_fields']
)

def __str__(self):
Expand Down
46 changes: 46 additions & 0 deletions src/django/api/models/partner_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import uuid
from django.db import models


class PartnerField(models.Model):
"""
Partner Field that will be protected for contribution.
"""
INT = 'int'
FLOAT = 'float'
STRING = 'string'
OBJECT = 'object'

TYPE_CHOICES = (
(INT, INT),
(FLOAT, FLOAT),
(STRING, STRING),
(OBJECT, OBJECT)
)

class Meta:
verbose_name_plural = "partner field"

uuid = models.UUIDField(
null=False,
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique identifier for the partner field.'
)
name = models.CharField(
max_length=200,
primary_key=True,
help_text=('The partner field name.'))
type = models.CharField(
max_length=200,
null=False,
blank=False,
choices=TYPE_CHOICES,
help_text=('The partner field type.'))

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

def __str__(self):
return self.name
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import ProductionLocationDataProcessor
from api.moderation_event_actions.creation.location_contribution \
.processors.geocoding_processor import GeocodingProcessor
from api.moderation_event_actions.creation.location_contribution \
.processors.permission_processor import PermissionProcessor
from api.moderation_event_actions.creation.dtos.create_moderation_event_dto \
import CreateModerationEventDTO

Expand All @@ -34,6 +36,7 @@ def serialize(
@staticmethod
def __setup_location_data_processors() -> ContributionProcessor:
location_data_processors = (
PermissionProcessor(),
SourceProcessor(),
ProductionLocationDataProcessor(),
GeocodingProcessor()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from typing import Dict, List

from rest_framework import status

from api.moderation_event_actions.creation.location_contribution \
.processors.contribution_processor import ContributionProcessor
from api.moderation_event_actions.creation.dtos.create_moderation_event_dto \
import CreateModerationEventDTO
from api.models.partner_field import PartnerField
from api.constants import APIV1CommonErrorMessages


class PermissionProcessor(ContributionProcessor):

def process(
self,
event_dto: CreateModerationEventDTO) -> CreateModerationEventDTO:

partner_fields = PartnerField.objects.all()
partner_field_names = [field.name for field in partner_fields]

if event_dto.raw_data and isinstance(event_dto.raw_data, dict):
matching_partner_field_names = [
key for key in event_dto.raw_data.keys()
if key in partner_field_names
]

if matching_partner_field_names:
contributor_partner_fields = event_dto.contributor \
.partner_fields.all()
contributor_partner_field_names = [
field.name for field in contributor_partner_fields
]

unauthorized_partner_fields = [
name for name in matching_partner_field_names
if name not in contributor_partner_field_names
]

if unauthorized_partner_fields:
validation_errors = self.__transform_fields_errors(
unauthorized_partner_fields
)
event_dto.errors = validation_errors
event_dto.status_code = status.HTTP_403_FORBIDDEN

return event_dto

return super().process(event_dto)

@staticmethod
def __transform_fields_errors(fields_errors: List[str]) -> Dict:
validation_errors = {
'detail': APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR,
'errors': []
}

for field_name in fields_errors:
validation_errors['errors'].append(
{
'field': field_name,
'detail': 'You do not have permission '
'to contribute to this field.'
}
)

return validation_errors
Loading