Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,6 @@ dvc
dvc/*

.DS_Store

# Git worktrees for feature branches
.worktrees/
1 change: 1 addition & 0 deletions DataSpace/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@
"search.documents.dataset_document": "dataset",
"search.documents.usecase_document": "usecase",
"search.documents.aimodel_document": "aimodel",
"search.documents.publication_document": "publication",
"search.documents.collaborative_document": "collaborative",
"search.documents.publisher_document.OrganizationPublisherDocument": "organization_publisher",
"search.documents.publisher_document.UserPublisherDocument": "user_publisher",
Expand Down
50 changes: 50 additions & 0 deletions api/management/commands/seed_resource_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Django management command to seed the initial Resource Type lookup values.

Idempotent — re-running only creates the types that are missing and never
duplicates or overwrites an admin's edits. Admins manage the list afterwards
(add / rename / deactivate) from the Django admin, no deploy required.

Usage:
python manage.py seed_resource_types
"""

from typing import Any

from django.core.management.base import BaseCommand
from django.db import transaction

from api.models import ResourceType

# The starting set of Resource Types. Admin-editable after seeding.
INITIAL_RESOURCE_TYPES = [
"Report",
"Article",
"Policy Brief",
"Research Paper",
"Case Study",
"Guide",
"Toolkit",
"Presentation",
"Fact Sheet",
"Working Paper",
]


class Command(BaseCommand):
help = "Seed the initial Resource Type lookup values (idempotent)"

def handle(self, *args: Any, **options: Any) -> None:
created_count = 0
with transaction.atomic():
for name in INITIAL_RESOURCE_TYPES:
_, created = ResourceType.objects.get_or_create(name=name)
if created:
created_count += 1

self.stdout.write(
self.style.SUCCESS(
f"✓ Resource Types seeded ({created_count} created, "
f"{len(INITIAL_RESOURCE_TYPES) - created_count} already present)"
)
)
12 changes: 4 additions & 8 deletions api/models/Collaborative.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from datetime import datetime
from typing import TYPE_CHECKING, Any, cast

from django.db import models
from django.core.validators import RegexValidator
from django.db import models
from django.utils.text import slugify

if TYPE_CHECKING:
Expand All @@ -13,7 +13,6 @@
from api.utils.enums import CollaborativeStatus, OrganizationRelationshipType
from api.utils.file_paths import _use_case_directory_path


slug_validator = RegexValidator(
regex=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
message="Slug must be lowercase and contain only alphanumeric characters and hyphens.",
Expand Down Expand Up @@ -47,15 +46,12 @@ class Collaborative(models.Model):
choices=CollaborativeStatus.choices,
)
datasets = models.ManyToManyField("api.Dataset", blank=True)
publications = models.ManyToManyField("api.Publication", blank=True)
use_cases = models.ManyToManyField("api.UseCase", blank=True)
tags = models.ManyToManyField("api.Tag", blank=True)
sectors = models.ManyToManyField(
"api.Sector", blank=True, related_name="collaboratives"
)
sectors = models.ManyToManyField("api.Sector", blank=True, related_name="collaboratives")
sdgs = models.ManyToManyField("api.SDG", blank=True, related_name="collaboratives")
geographies = models.ManyToManyField(
"api.Geography", blank=True, related_name="collaboratives"
)
geographies = models.ManyToManyField("api.Geography", blank=True, related_name="collaboratives")
contributors = models.ManyToManyField(
"authorization.User", blank=True, related_name="contributed_collaboratives"
)
Expand Down
190 changes: 190 additions & 0 deletions api/models/Publication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import uuid
from typing import TYPE_CHECKING, Any

from django.db import models
from django.db.models import Q
from django.utils.text import slugify

from api.utils.enums import DatasetLicense, PublicationBlockType, PublicationStatus
from api.utils.file_paths import _publication_block_directory_path

if TYPE_CHECKING:
from api.models.Organization import Organization
from api.models.ResourceType import ResourceType
from authorization.models import User


class Publication(models.Model):
"""A top-level Resource — a container for human-authored content.

Peer to Dataset and AI Model: a UUID/slug entity owned by an organization or
an individual user, with typed metadata columns, a publish/unpublish status,
an ordered list of content blocks, and its own search + linking surface.
(Internal name ``Publication`` to avoid colliding with the file-inside-a-dataset
``Resource``; the UI always labels it "Resource".)
"""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=300, unique=False, blank=True)
description = models.TextField(blank=True, null=True)
slug = models.SlugField(max_length=255, unique=True)

organization = models.ForeignKey(
"api.Organization",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="publications",
)
user = models.ForeignKey(
"authorization.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="publications",
)

# Typed metadata columns — a fixed, known schema (no dynamic/EAV fields).
authors = models.JSONField(default=list, blank=True)
publication_date = models.DateField(null=True, blank=True)
license = models.CharField(
max_length=50,
default=DatasetLicense.CC_BY_4_0_ATTRIBUTION,
choices=DatasetLicense.choices,
)
external_source_link = models.URLField(blank=True, null=True)

# Structural / faceted columns.
status = models.CharField(
max_length=50,
default=PublicationStatus.DRAFT,
choices=PublicationStatus.choices,
)
resource_type = models.ForeignKey(
"api.ResourceType",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="publications",
)
sectors = models.ManyToManyField("api.Sector", blank=True, related_name="publications")
geographies = models.ManyToManyField("api.Geography", blank=True, related_name="publications")
download_count = models.IntegerField(default=0)

created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)

def save(self, *args: Any, **kwargs: Any) -> None:
if not self.slug:
base_slug = slugify(self.title)
slug = base_slug
counter = 1
while Publication.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
super().save(*args, **kwargs)

@property
def is_individual_publication(self) -> bool:
"""True when this Resource is owned by an individual, not an organization."""
return self.organization is None and self.user is not None

@property
def sectors_indexing(self) -> list[str]:
"""Sector names for Elasticsearch indexing."""
return [sector.name for sector in self.sectors.all()] # type: ignore

@property
def geographies_indexing(self) -> list[str]:
"""Geography names for Elasticsearch indexing."""
return [geo.name for geo in self.geographies.all()] # type: ignore

@property
def resource_type_indexing(self) -> str:
"""Resource-type name for Elasticsearch indexing (empty when unset)."""
return self.resource_type.name if self.resource_type else ""

def __str__(self) -> str:
return self.title

class Meta:
verbose_name = "Publication"
verbose_name_plural = "Publications"
db_table = "publication"
ordering = ["-modified"]
indexes = [
models.Index(fields=["organization", "-modified"]),
models.Index(fields=["user", "-modified"]),
models.Index(fields=["status"]),
]


class PublicationBlock(models.Model):
"""One content block in a Resource — a file XOR a YouTube embed, at a position.

Blocks are ordered by ``position`` within their publication. Each block is
exactly one of two shapes, enforced by the ``file_xor_youtube`` check
constraint: a FILE block carries an uploaded file (with its name/format/size),
a YOUTUBE block carries a video url and its extracted id. Neither-both nor
neither-set is a valid row.
"""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
publication = models.ForeignKey(
"api.Publication",
on_delete=models.CASCADE,
related_name="blocks",
)
position = models.PositiveIntegerField(default=0)
block_type = models.CharField(
max_length=20,
choices=PublicationBlockType.choices,
)

# FILE block fields.
file = models.FileField(
upload_to=_publication_block_directory_path,
max_length=300,
blank=True,
)
file_name = models.CharField(max_length=300, blank=True)
file_format = models.CharField(max_length=50, blank=True)
file_size = models.BigIntegerField(null=True, blank=True)

# YOUTUBE block fields.
youtube_url = models.URLField(blank=True, null=True)
youtube_video_id = models.CharField(max_length=20, blank=True)

created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)

def __str__(self) -> str:
return f"{self.block_type} block #{self.position} of {self.publication_id}"

class Meta:
db_table = "publication_block"
ordering = ["position"]
indexes = [
models.Index(fields=["publication", "position"]),
]
constraints = [
# Runtime is Django 5.0 (``check=``); the pinned django-stubs is
# newer and only knows the 5.1 ``condition=`` spelling, hence the
# ignore. Switch to ``condition=`` when the runtime moves to 5.1+.
models.CheckConstraint( # type: ignore[call-arg]
name="publicationblock_file_xor_youtube",
check=(
Q(
block_type=PublicationBlockType.FILE,
youtube_url__isnull=True,
)
& ~Q(file="")
)
| Q(
block_type=PublicationBlockType.YOUTUBE,
file="",
youtube_url__isnull=False,
),
)
]
33 changes: 33 additions & 0 deletions api/models/ResourceType.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import uuid
from typing import Any

from django.db import models
from django.utils.text import slugify


class ResourceType(models.Model):
"""Admin-managed lookup for a Resource's type (Report, Article, Policy Brief, ...).

A flat, non-hierarchical list — no parent self-FK. Admins can add, rename,
or deactivate a type without a code release. Deactivating (``is_active=False``)
keeps historical references intact while hiding the type from new selections.
"""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=75, unique=True, null=False, blank=False)
description = models.CharField(max_length=1000, null=True, blank=True)
slug = models.SlugField(max_length=75, null=True, blank=False, unique=True)
is_active = models.BooleanField(default=True)

def save(self, *args: Any, **kwargs: Any) -> None:
self.slug = slugify(self.name)
super().save(*args, **kwargs)

def __str__(self) -> str:
return str(self.name)

class Meta:
db_table = "resource_type"
verbose_name = "Resource Type"
verbose_name_plural = "Resource Types"
ordering = ["name"]
5 changes: 2 additions & 3 deletions api/models/UseCase.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class UseCase(models.Model):
max_length=50, default=UseCaseStatus.DRAFT, choices=UseCaseStatus.choices
)
datasets = models.ManyToManyField("api.Dataset", blank=True)
publications = models.ManyToManyField("api.Publication", blank=True)
tags = models.ManyToManyField("api.Tag", blank=True)
running_status = models.CharField(
max_length=50,
Expand All @@ -45,9 +46,7 @@ class UseCase(models.Model):
)
sectors = models.ManyToManyField("api.Sector", blank=True, related_name="usecases")
sdgs = models.ManyToManyField("api.SDG", blank=True, related_name="usecases")
geographies = models.ManyToManyField(
"api.Geography", blank=True, related_name="usecases"
)
geographies = models.ManyToManyField("api.Geography", blank=True, related_name="usecases")
contributors = models.ManyToManyField(
"authorization.User", blank=True, related_name="contributed_usecases"
)
Expand Down
2 changes: 2 additions & 0 deletions api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from api.models.Organization import Organization
from api.models.PromptDataset import PromptDataset
from api.models.PromptResource import PromptResource
from api.models.Publication import Publication, PublicationBlock
from api.models.Resource import (
Resource,
ResourceDataTable,
Expand All @@ -26,6 +27,7 @@
from api.models.ResourceChartImage import ResourceChartImage
from api.models.ResourceMetadata import ResourceMetadata
from api.models.ResourceSchema import ResourceSchema
from api.models.ResourceType import ResourceType
from api.models.SDG import SDG
from api.models.Sector import Sector
from api.models.SerializableJSONField import SerializableJSONField
Expand Down
Loading
Loading