Skip to content

Commit

Permalink
Merge pull request #111 from CentreForDigitalHumanities/feature/episo…
Browse files Browse the repository at this point in the history
…de-form

Feature/update episode form
  • Loading branch information
lukavdplas authored Aug 20, 2024
2 parents b09166c + 1dcb328 commit fc2a9ce
Show file tree
Hide file tree
Showing 46 changed files with 1,195 additions and 11 deletions.
49 changes: 49 additions & 0 deletions backend/event/mutations/UpdateEpisodeMutation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from graphene import ID, Boolean, InputObjectType, List, NonNull, ResolveInfo, String
from django.core.exceptions import ObjectDoesNotExist
from event.models import Episode
from graphql_app.LettercraftMutation import LettercraftMutation

from graphql_app.types.LettercraftErrorType import LettercraftErrorType


class UpdateEpisodeMutationInput(InputObjectType):
id = ID(required=True)
name = String()
book = String()
chapter = String()
page = String()
designators = List(NonNull(String))
summary = String()
categories = List(NonNull(ID))


class UpdateEpisodeMutation(LettercraftMutation):
ok = Boolean(required=True)
errors = List(NonNull(LettercraftErrorType), required=True)

django_model = Episode

class Arguments:
input = UpdateEpisodeMutationInput(required=True)

@classmethod
def mutate(cls, root: None, info: ResolveInfo, input: UpdateEpisodeMutationInput):
try:
retrieved_object = cls.get_or_create_object(info, input)
except ObjectDoesNotExist as e:
error = LettercraftErrorType(field="id", messages=[str(e)])
return cls(ok=False, errors=[error]) # type: ignore

episode = retrieved_object.object

try:
cls.mutate_object(input, episode, info)
except ObjectDoesNotExist as field:
error = LettercraftErrorType(
field=str(field), messages=["Related object cannot be found."]
)
return cls(ok=False, errors=[error]) # type: ignore

episode.save()

return cls(ok=True, errors=[]) # type: ignore
13 changes: 11 additions & 2 deletions backend/event/types/EpisodeType.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from graphene import ResolveInfo
from graphene import List, NonNull, ResolveInfo
from graphene_django import DjangoObjectType
from core.types.EntityDescriptionType import EntityDescriptionType
from django.db.models import QuerySet

from event.models import Episode
from event.models import Episode, EpisodeCategory
from event.types.EpisodeCategoryType import EpisodeCategoryType


class EpisodeType(EntityDescriptionType, DjangoObjectType):
categories = List(NonNull(EpisodeCategoryType), required=True)

class Meta:
model = Episode
fields = [
Expand All @@ -26,3 +29,9 @@ def get_queryset(
info: ResolveInfo,
) -> QuerySet[Episode]:
return queryset.all()

@staticmethod
def resolve_categories(
parent: Episode, info: ResolveInfo
) -> QuerySet[EpisodeCategory]:
return parent.categories.all()
187 changes: 187 additions & 0 deletions backend/graphql_app/LettercraftMutation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
from typing import Type
from dataclasses import dataclass
from graphene import InputObjectType, Mutation, ResolveInfo
from django.db.models import Model
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
from graphene_django.types import DjangoObjectType
from graphene_django.registry import get_global_registry


@dataclass
class RetrievedObject:
object: Model
created: bool


class LettercraftMutation(Mutation):
"""
An extension of Graphene's base Mutation class.
"""

django_model: Type[Model] | None = None

@classmethod
def get_or_create_object(
cls, info: ResolveInfo, mutation_input: InputObjectType
) -> RetrievedObject:
"""
Retrieves a Django model instance by id. If the instance does not exist, a new instance is created (but not saved to the database). The instance will be empty, i.e. will not have any (obligatory) fields set.
Any mutation making use of this method should have a class field 'django_model' defined referring to the class of the object that is being mutated.
Args:
- mutation_input (InputObjectType): The mutation input.
- info (ResolveInfo): An object containing the request and user information.
Exceptions:
- ImproperlyConfigured is raised in two cases:
1. when the mutation class implementing LettercraftMutation does not have the required class field 'django_model' defined;
2. when the corresponding Graphene type cannot be found. This exception should not be caught.
- ObjectDoesNotExist is raised when an ID is provided but the corresponding object is not found or inaccessible to the user making the request.
"""

if cls.django_model is None:
raise ImproperlyConfigured(
"Mutation class must have a class field 'django_model' defined."
)

model = cls.django_model

id = getattr(mutation_input, "id", None)

# The registry is a global object that holds all the models and types.
# It is used to get the corresponding Graphene type for a Django model.
registry = get_global_registry()
graphene_type = registry.get_type_for_model(model)

if graphene_type is None:
raise ImproperlyConfigured("Graphene type not found for model.")

if id is None:
return RetrievedObject(object=model(), created=True)

try:
retrieved = graphene_type.get_queryset(model.objects, info).get(pk=id)
except model.DoesNotExist:
raise ObjectDoesNotExist("Object not found.")

return RetrievedObject(object=retrieved, created=False)

@staticmethod
def mutate_object(
input: InputObjectType,
object_instance: Model,
resolve_info: ResolveInfo,
excluded_fields: list[str] = [],
) -> None:
"""
Updates both simple and related fields in a GraphQL mutation.
Simple fields are simply set on the mutated object.
For related fields, the corresponding Django models and Graphene types are retrieved,
and type.get_queryset() is called to ensure the user has access to those particular models.
Args:
- input (dict): A dictionary containing the updated values for the related fields.
object_instance (Model): The Django model instance to be updated.
- resolve_info (ResolveInfo): An object containing the request and user information.
excluded_fields (List[str]): A list of fields to be excluded from the update.
Exceptions:
- ObjectDoesNotExist is raised when an ID of a ForeignKey field is provided but the corresponding object is not found or inaccessible to the user making the request. This exception exposes the name of the field.
"""

# The Graphene registry maps Django models to Graphene types.
registry = get_global_registry()

simple_fields = []
one_to_many_fields = []
many_to_many_fields = []

for key, value in input.items():
# Skip excluded fields
if key in excluded_fields:
continue

field = object_instance._meta.get_field(key)

# Triage
if field.many_to_many is True:
many_to_many_fields.append(key)
elif field.many_to_one is True:
one_to_many_fields.append(key)
else:
simple_fields.append(key)

# Update simple fields
for key in simple_fields:
try:
value = getattr(input, key)
setattr(object_instance, key, value)
except AttributeError:
pass

# Update one-to-many fields.
# Only selects existing objects on the 'many' side
# of the relationship.
for key in one_to_many_fields:
try:
value = getattr(input, key)
except AttributeError:
continue

if value is None or value == "":
setattr(object_instance, key, None)
continue

field = object_instance._meta.get_field(key)
django_model = field.related_model # type: Model | None

if django_model is None:
continue

graphene_type = registry.get_type_for_model(django_model)

try:
accessible_object = graphene_type.get_queryset(
django_model.objects, resolve_info
).get(id=value)
except django_model.DoesNotExist:
raise ObjectDoesNotExist(key)

setattr(object_instance, key, accessible_object)

# Saving the object instance so we can use it for many-to-many fields.
object_instance.save()

# Update many-to-many fields
for key in many_to_many_fields:
try:
value = getattr(input, key)
except AttributeError:
continue

if value is None:
getattr(object_instance, key).set([])
continue

field = object_instance._meta.get_field(key)
django_model = field.related_model
graphene_type = registry.get_type_for_model(django_model)

accessible_objects = graphene_type.get_queryset(
django_model.objects, resolve_info
)

new_ids = [
item["id"]
for item in accessible_objects.filter(id__in=value).values("id")
]

getattr(object_instance, key).set(new_ids)

# All subclasses of Mutation must implement the mutate method.
@classmethod
def mutate(cls, root: None, info: ResolveInfo, *args, **kwargs):
cls.mutate(root, info, *args, **kwargs)
2 changes: 2 additions & 0 deletions backend/graphql_app/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from space.queries import SpaceQueries
from user.queries import UserQueries
from source.mutations.UpdateOrCreateSourceMutation import UpdateOrCreateSourceMutation
from event.mutations.UpdateEpisodeMutation import UpdateEpisodeMutation


class Query(
Expand All @@ -23,6 +24,7 @@ class Query(

class Mutation(ObjectType):
update_or_create_source = UpdateOrCreateSourceMutation.Field()
update_episode = UpdateEpisodeMutation.Field()


schema = Schema(query=Query, mutation=Mutation)
13 changes: 13 additions & 0 deletions backend/graphql_app/types/LettercraftErrorType.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import List
from graphene_django.types import ErrorType


class LettercraftErrorType(ErrorType):
"""
A simple wrapper around Graphene-Django's ErrorType with a constructor.
"""

def __init__(self, field: str, messages: List[str]):
super().__init__()
self.field = field
self.messages = messages
Loading

0 comments on commit fc2a9ce

Please sign in to comment.