Skip to content

Commit 68c27c0

Browse files
Merge pull request #5989 from uktrade/feature/CLS2-1304-serve-sharepoint-documents
Serve SharePoint documents from generic view
2 parents e910674 + 787cf42 commit 68c27c0

13 files changed

+641
-8
lines changed

config/api_urls.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from datahub.company_referral import urls as company_referral_urls
1515
from datahub.dataset import urls as dataset_urls
1616
from datahub.dnb_api import urls as dnb_api_urls
17+
from datahub.documents import urls as document_urls
1718
from datahub.event import urls as event_urls
1819
from datahub.export_win import urls as export_win_urls
1920
from datahub.feature_flag import urls as feature_flag_urls
@@ -106,7 +107,9 @@
106107
include((investment_lead_urls, 'investment-lead'), namespace='investment-lead'),
107108
),
108109
path(
109-
'company-activity/', include((company_activity_urls,
110-
'company-activity'), namespace='company-activity'),
110+
'company-activity/', include(
111+
(company_activity_urls, 'company-activity'), namespace='company-activity',
112+
),
111113
),
114+
path('document/', include((document_urls, 'document'), namespace='document')),
112115
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 4.2.19 on 2025-03-04 20:51
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
('documents', '0005_switch_to_booleanfield_with_null_kwarg'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='SharePointDocument',
19+
fields=[
20+
('created_on', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
21+
('modified_on', models.DateTimeField(auto_now=True, null=True)),
22+
('archived', models.BooleanField(default=False)),
23+
('archived_on', models.DateTimeField(blank=True, null=True)),
24+
('archived_reason', models.TextField(blank=True, null=True)),
25+
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
26+
('title', models.CharField(blank=True, default='', max_length=255)),
27+
('url', models.URLField(max_length=255)),
28+
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
29+
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
30+
('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
31+
],
32+
options={
33+
'abstract': False,
34+
},
35+
),
36+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 4.2.19 on 2025-03-04 20:51
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
('contenttypes', '0002_remove_content_type_name'),
14+
('documents', '0006_sharepointdocument'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='GenericDocument',
20+
fields=[
21+
('created_on', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
22+
('modified_on', models.DateTimeField(auto_now=True, null=True)),
23+
('archived', models.BooleanField(default=False)),
24+
('archived_on', models.DateTimeField(blank=True, null=True)),
25+
('archived_reason', models.TextField(blank=True, null=True)),
26+
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
27+
('document_object_id', models.UUIDField()),
28+
('related_object_id', models.UUIDField()),
29+
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
30+
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
31+
('document_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='contenttypes.contenttype')),
32+
('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
33+
('related_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_documents', to='contenttypes.contenttype')),
34+
],
35+
options={
36+
'indexes': [models.Index(fields=['document_type', 'document_object_id', 'related_object_type', 'related_object_id'], name='documents_g_documen_2aba68_idx')],
37+
},
38+
),
39+
]

datahub/documents/models.py

+60
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from logging import getLogger
33

44
from django.conf import settings
5+
from django.contrib.contenttypes.fields import GenericForeignKey
6+
from django.contrib.contenttypes.models import ContentType
57
from django.db import models, transaction
68
from django.utils.timezone import now
79

@@ -201,3 +203,61 @@ class AbstractEntityDocumentModel(BaseModel):
201203

202204
class Meta:
203205
abstract = True
206+
207+
208+
class SharePointDocument(BaseModel, ArchivableModel):
209+
"""Model to represent documents in SharePoint."""
210+
211+
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
212+
title = models.CharField(max_length=settings.CHAR_FIELD_MAX_LENGTH, blank=True, default='')
213+
url = models.URLField(max_length=settings.CHAR_FIELD_MAX_LENGTH)
214+
215+
def __str__(self):
216+
return self.title
217+
218+
219+
class GenericDocument(BaseModel, ArchivableModel):
220+
"""A single model to represent documents of varying types.
221+
222+
The idea behind this model is to serve as a single interaction point for documents,
223+
irrespective of type. For example, those uploaded to an S3 bucket, or those stored in
224+
SharePoint. Each type of document will have different CRUD operations, but this model,
225+
along with it's serializer and viewset, will enable all actions from a single endpoint.
226+
227+
This model has two generic relations:
228+
1. To the type-specific document model instance (e.g. SharePointDocument or UploadableDocument)
229+
2. To the model instance the document relates to (e.g. Company, or InvestmentProject)
230+
"""
231+
232+
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
233+
234+
# Generic relation to type-specific document model instance
235+
document_type = models.ForeignKey(
236+
ContentType,
237+
on_delete=models.CASCADE,
238+
related_name='documents',
239+
)
240+
document_object_id = models.UUIDField()
241+
document = GenericForeignKey('document_type', 'document_object_id')
242+
243+
# Generic relation to model instance the document relates to
244+
related_object_type = models.ForeignKey(
245+
ContentType,
246+
on_delete=models.CASCADE,
247+
related_name='related_documents',
248+
)
249+
related_object_id = models.UUIDField()
250+
related_object = GenericForeignKey('related_object_type', 'related_object_id')
251+
252+
class Meta:
253+
indexes = [
254+
models.Index(fields=[
255+
'document_type',
256+
'document_object_id',
257+
'related_object_type',
258+
'related_object_id',
259+
]),
260+
]
261+
262+
def __str__(self):
263+
return f'{self.document} for {self.related_object}'

datahub/documents/serializers.py

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from django.contrib.contenttypes.models import ContentType
2+
from rest_framework import serializers
3+
4+
from datahub.company.models import (
5+
Advisor,
6+
Company,
7+
)
8+
from datahub.core.serializers import NestedRelatedField
9+
from datahub.documents.models import (
10+
GenericDocument,
11+
SharePointDocument,
12+
)
13+
from datahub.documents.utils import format_content_type
14+
15+
16+
class SharePointDocumentSerializer(serializers.ModelSerializer):
17+
18+
created_by = NestedRelatedField(Advisor, extra_fields=['name', 'email'])
19+
modified_by = NestedRelatedField(Advisor, extra_fields=['name', 'email'])
20+
21+
class Meta:
22+
model = SharePointDocument
23+
fields = '__all__'
24+
25+
26+
class DocumentRelatedField(serializers.RelatedField):
27+
"""Serializer field for the GenericDocument.document field.
28+
29+
Currently, only SharePointDocument objects are supported.
30+
31+
To add support for another document type, add an elif statement to the to_representation
32+
method to check for the new model and set the serializer accordingly.
33+
34+
For example:
35+
36+
```
37+
elif isinstance(instance, YourDocumentModel):
38+
serializer = YourDocumentSerializer(instance)
39+
```
40+
"""
41+
42+
def to_representation(self, instance):
43+
"""Convert model instance to built-in Python (JSON friendly) data types."""
44+
if isinstance(instance, SharePointDocument):
45+
serializer = SharePointDocumentSerializer(instance)
46+
else:
47+
raise Exception(f'Unexpected document type: {type(instance)}')
48+
return serializer.data
49+
50+
51+
class RelatedObjectRelatedField(serializers.RelatedField):
52+
"""Serializer field for the GenericDocument.related_object field.
53+
54+
Currently, only Company objects are support.
55+
56+
To add support for another type of related object, add the model to the tuple
57+
in the `isinstance` call in the to_representation method - e.g.
58+
`isinstance(instance, (Company, YourModel, ...))`. The model must contain the fields
59+
`id` and `name`, otherwise, you will need to add an elif statement and customise
60+
the return object accordingly.
61+
"""
62+
63+
def to_representation(self, instance):
64+
"""Convert model instance to built-in Python (JSON friendly) data types."""
65+
if isinstance(instance, (Company)):
66+
return {
67+
'id': str(instance.id),
68+
'name': instance.name,
69+
}
70+
content_type = ContentType.objects.get_for_model(instance)
71+
raise Exception(f'Unexpected type of related object: {format_content_type(content_type)}')
72+
73+
74+
class GenericDocumentRetrieveSerializer(serializers.ModelSerializer):
75+
"""Serializer for retrieving Generic Document objects."""
76+
77+
created_by = NestedRelatedField(Advisor, extra_fields=['name', 'email'])
78+
modified_by = NestedRelatedField(Advisor, extra_fields=['name', 'email'])
79+
document = DocumentRelatedField(read_only=True)
80+
related_object = RelatedObjectRelatedField(read_only=True)
81+
82+
class Meta:
83+
model = GenericDocument
84+
fields = '__all__'
85+
86+
def to_representation(self, instance):
87+
"""Convert model instance to built-in Python (JSON friendly) data types."""
88+
representation = super().to_representation(instance)
89+
representation.update({
90+
'document_type': format_content_type(instance.document_type),
91+
'related_object_type': format_content_type(instance.related_object_type),
92+
})
93+
return representation

datahub/documents/test/factories.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import factory
44

5-
from datahub.company.test.factories import AdviserFactory
5+
from datahub.company.test.factories import (
6+
AdviserFactory,
7+
CompanyFactory,
8+
)
69
from datahub.documents.models import UploadStatus
710

811

@@ -22,3 +25,35 @@ class DocumentFactory(factory.django.DjangoModelFactory):
2225

2326
class Meta:
2427
model = 'documents.Document'
28+
29+
30+
class SharePointDocumentFactory(factory.django.DjangoModelFactory):
31+
32+
title = factory.Faker('text', max_nb_chars=20)
33+
url = factory.Faker('url')
34+
created_by = factory.SubFactory(AdviserFactory)
35+
modified_by = factory.SubFactory(AdviserFactory)
36+
37+
class Meta:
38+
model = 'documents.SharePointDocument'
39+
40+
41+
class CompanySharePointDocumentFactory(factory.django.DjangoModelFactory):
42+
"""Generates a GenericDocument instance linking a Company to a SharePointDocument."""
43+
44+
document = factory.SubFactory(SharePointDocumentFactory)
45+
related_object = factory.SubFactory(CompanyFactory)
46+
created_by = factory.SubFactory(AdviserFactory)
47+
modified_by = factory.SubFactory(AdviserFactory)
48+
archived = False
49+
50+
class Meta:
51+
model = 'documents.GenericDocument'
52+
53+
@factory.post_generation
54+
def sync_created_and_modified_on_document_instance(obj, create, extracted, **kwargs): # noqa
55+
obj.document.created_by = obj.created_by
56+
obj.document.modified_by = obj.modified_by
57+
obj.document.created_on = obj.created_on
58+
obj.document.modified_on = obj.modified_on
59+
obj.document.save()
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import pytest
2+
3+
from django.contrib.contenttypes.models import ContentType
4+
5+
from datahub.company.test.factories import CompanyFactory
6+
from datahub.documents.models import GenericDocument
7+
from datahub.documents.serializers import (
8+
GenericDocumentRetrieveSerializer,
9+
SharePointDocumentSerializer,
10+
)
11+
from datahub.documents.test.factories import (
12+
CompanySharePointDocumentFactory,
13+
SharePointDocumentFactory,
14+
)
15+
from datahub.documents.test.test_utils import (
16+
assert_retrieved_generic_document,
17+
assert_retrieved_sharepoint_document,
18+
)
19+
from datahub.investment.project.test.factories import InvestmentProjectFactory
20+
21+
22+
pytestmark = pytest.mark.django_db
23+
24+
25+
class TestSharePointDocumentSerializer:
26+
"""Tests for SharePointDocumentSerializer"""
27+
28+
def test_serializing_instance_returns_expected_fields(self):
29+
sharepoint_document = SharePointDocumentFactory()
30+
serializer = SharePointDocumentSerializer(sharepoint_document)
31+
assert_retrieved_sharepoint_document(sharepoint_document, serializer.data)
32+
33+
34+
class TestGenericDocumentRetrieveSerializer:
35+
"""Tests for GenericDocumentRetrieveSerializer"""
36+
37+
def test_serializing_instance_returns_expected_fields(self):
38+
generic_document = CompanySharePointDocumentFactory()
39+
serializer = GenericDocumentRetrieveSerializer(generic_document)
40+
assert_retrieved_generic_document(generic_document, serializer.data)
41+
42+
def test_serializer_raises_error_if_unsupported_document_type(self):
43+
unsupported_document = CompanySharePointDocumentFactory()
44+
unsupported_document_type = ContentType.objects.get_for_model(unsupported_document)
45+
46+
company = CompanyFactory()
47+
company_type = ContentType.objects.get_for_model(company)
48+
49+
generic_document = GenericDocument.objects.create(
50+
document_type=unsupported_document_type,
51+
document_object_id=unsupported_document.id,
52+
related_object_type=company_type,
53+
related_object_id=company.id,
54+
)
55+
with pytest.raises(Exception):
56+
serializer = GenericDocumentRetrieveSerializer(generic_document)
57+
serializer.data
58+
59+
def test_serializer_raises_error_if_unsupported_related_object_type(self):
60+
document = SharePointDocumentFactory()
61+
document_type = ContentType.objects.get_for_model(document)
62+
63+
unsupported_related_object = InvestmentProjectFactory()
64+
unsupported_related_object_type = ContentType.objects.get_for_model(
65+
unsupported_related_object,
66+
)
67+
68+
generic_document = GenericDocument.objects.create(
69+
document_type=document_type,
70+
document_object_id=document.id,
71+
related_object_type=unsupported_related_object_type,
72+
related_object_id=unsupported_related_object.id,
73+
)
74+
with pytest.raises(Exception):
75+
serializer = GenericDocumentRetrieveSerializer(generic_document)
76+
serializer.data

0 commit comments

Comments
 (0)