22import logging
33import traceback
44from abc import ABC
5+ from collections .abc import Sequence
6+ from typing import Any , ClassVar , Optional
57
68import django .db .models
79import graphene
2123logger = logging .getLogger (__name__ )
2224
2325
26+ def _require_io_setting (mutation_cls : type , name : str ) -> Any :
27+ """Raise ``NotImplementedError`` if ``cls.IOSettings.<name>`` is missing or ``None``."""
28+ io_settings = getattr (mutation_cls , "IOSettings" , None )
29+ if io_settings is None :
30+ raise NotImplementedError (
31+ f"{ mutation_cls .__name__ } must define an IOSettings class."
32+ )
33+ value = getattr (io_settings , name , None )
34+ if value is None :
35+ raise NotImplementedError (
36+ f"{ mutation_cls .__name__ } .IOSettings.{ name } must be set by the "
37+ f"subclass."
38+ )
39+ return value
40+
41+
2442class OpenContractsNode (Node ):
2543 class Meta :
2644 name = "Node"
2745
2846 @classmethod
29- def get_node_from_global_id (cls , info , global_id , only_type = None ):
47+ def get_node_from_global_id (
48+ cls , info : Any , global_id : str , only_type : Any | None = None
49+ ) -> Any :
3050
3151 _type , _id = from_global_id (global_id )
3252
@@ -63,7 +83,7 @@ class Meta:
6383
6484 total_count = graphene .Int ()
6585
66- def resolve_total_count (root , info , ** kwargs ):
86+ def resolve_total_count (root , info , ** kwargs ) -> Any :
6787 if isinstance (root .iterable , django .db .models .QuerySet ):
6888 return root .iterable .count ()
6989 else :
@@ -72,7 +92,8 @@ def resolve_total_count(root, info, **kwargs):
7292
7393class DRFDeletion (graphene .Mutation ):
7494 class IOSettings (ABC ):
75- lookup_field = "id"
95+ lookup_field : ClassVar [str ] = "id"
96+ model : ClassVar [Optional [type [django .db .models .Model ]]] = None
7697
7798 class Arguments :
7899 id = graphene .String (required = False )
@@ -83,14 +104,26 @@ class Arguments:
83104 @classmethod
84105 @login_required
85106 @graphql_ratelimit (rate = RateLimits .WRITE_LIGHT )
86- def mutate (cls , root , info , * args , ** kwargs ):
107+ def mutate (cls , root , info , * args , ** kwargs ) -> "DRFDeletion" :
87108
109+ # Unlike ``DRFMutation.mutate`` below, this method intentionally does
110+ # NOT wrap the body in ``except Exception``. Errors (including the
111+ # ``NotImplementedError`` from ``_require_io_setting`` and the
112+ # ``ValueError`` for missing lookup args) propagate to the GraphQL
113+ # framework as raw execution errors.
88114 ok = False
89115
90- id = from_global_id (kwargs .get (cls .IOSettings .lookup_field , None ))[1 ]
116+ model = _require_io_setting (cls , "model" )
117+ lookup_field = cls .IOSettings .lookup_field
118+ lookup_value = kwargs .get (lookup_field )
119+ if lookup_value is None :
120+ raise ValueError (
121+ f"'{ lookup_field } ' is required to identify the object to delete."
122+ )
123+ id = from_global_id (lookup_value )[1 ]
91124 # Filter through visible_to_user() to prevent IDOR -- returns same
92125 # DoesNotExist error whether object is missing or user lacks access.
93- obj = cls . IOSettings . model .objects .visible_to_user (info .context .user ).get (pk = id )
126+ obj = model .objects .visible_to_user (info .context .user ).get (pk = id )
94127
95128 # if there's a user lock, only the lock holder (or superuser) can proceed
96129 if hasattr (obj , "user_lock" ) and obj .user_lock is not None :
@@ -123,11 +156,13 @@ def mutate(cls, root, info, *args, **kwargs):
123156
124157class DRFMutation (graphene .Mutation ):
125158 class IOSettings (ABC ):
126- pk_fields : list [str | int ] = []
127- lookup_field = "id"
128- model : django .db .models .Model = None
129- graphene_model : DjangoObjectType = None
130- serializer = None
159+ # Frozen default — subclasses override with their own list/tuple.
160+ # Using a tuple here avoids the shared-mutable-default footgun.
161+ pk_fields : ClassVar [Sequence [str ]] = ()
162+ lookup_field : ClassVar [str ] = "id"
163+ model : ClassVar [Optional [type [django .db .models .Model ]]] = None
164+ graphene_model : ClassVar [Optional [type [DjangoObjectType ]]] = None
165+ serializer : ClassVar [Optional [type [serializers .Serializer ]]] = None
131166
132167 class Arguments :
133168 pass
@@ -137,7 +172,7 @@ class Arguments:
137172 obj_id = graphene .ID ()
138173
139174 @staticmethod
140- def format_validation_error (ve ) :
175+ def format_validation_error (ve : serializers . ValidationError ) -> str :
141176 """Surface validation errors with clean formatting.
142177
143178 ``str(ValidationError)`` renders as
@@ -158,7 +193,7 @@ def format_validation_error(ve):
158193 @classmethod
159194 @login_required
160195 @graphql_ratelimit (rate = RateLimits .WRITE_MEDIUM )
161- def mutate (cls , root , info , * args , ** kwargs ):
196+ def mutate (cls , root , info , * args , ** kwargs ) -> "DRFMutation" :
162197
163198 ok = False
164199 obj_id = None
@@ -175,20 +210,25 @@ def mutate(cls, root, info, *args, **kwargs):
175210 raise ValueError ("No user in this request..." )
176211
177212 logger .info (f"DRFMutation - kwargs: { kwargs } " )
178- serializer = cls .IOSettings .serializer
213+ serializer = _require_io_setting (cls , "serializer" )
214+ model = _require_io_setting (cls , "model" )
215+ # ``graphene_model`` is the class itself; ``__name__`` is the GraphQL
216+ # type name (e.g. ``"CorpusType"``). ``__class__.__name__`` would
217+ # return the metaclass name (``"SubclassWithMeta_Meta"``) which is
218+ # the bug this PR fixes — kept dereferenced as ``__name__`` below.
219+ graphene_model = _require_io_setting (cls , "graphene_model" )
179220
180221 if hasattr (cls .IOSettings , "pk_fields" ):
181222 for pk_field in cls .IOSettings .pk_fields :
182223 if pk_field in kwargs :
183- if isinstance ( kwargs [pk_field ], list ):
184- pk_value = []
185- for global_id in kwargs [pk_field ]:
186- # global_id is already the ID string, not a key
187- pk_value . append ( from_global_id ( global_id )[ 1 ])
224+ raw_value = kwargs [pk_field ]
225+ if isinstance ( raw_value , list ):
226+ kwargs [pk_field ] = [
227+ from_global_id ( global_id )[ 1 ] for global_id in raw_value
228+ ]
188229 else :
189- logger .info (f"pk field is: { kwargs .get (pk_field , None )} " )
190- pk_value = from_global_id (kwargs .get (pk_field , None ))[1 ]
191- kwargs [pk_field ] = pk_value
230+ logger .info (f"pk field is: { raw_value } " )
231+ kwargs [pk_field ] = from_global_id (raw_value )[1 ]
192232
193233 # Check if lookup_field exists in IOSettings and if it's in kwargs
194234 # This allows create mutations to work without requiring lookup_field
@@ -201,10 +241,8 @@ def mutate(cls, root, info, *args, **kwargs):
201241 logger .info ("Lookup_field specified - update" )
202242 # Filter through visible_to_user() to prevent IDOR --
203243 # returns same DoesNotExist whether missing or no access.
204- obj = cls .IOSettings .model .objects .visible_to_user (
205- info .context .user
206- ).get (
207- pk = from_global_id (kwargs .get (cls .IOSettings .lookup_field , None ))[1 ]
244+ obj = model .objects .visible_to_user (info .context .user ).get (
245+ pk = from_global_id (kwargs [cls .IOSettings .lookup_field ])[1 ]
208246 )
209247
210248 logger .info (f"Retrieved obj: { obj } " )
@@ -240,9 +278,7 @@ def mutate(cls, root, info, *args, **kwargs):
240278 obj_serializer .save ()
241279 ok = True
242280 message = "Success"
243- obj_id = to_global_id (
244- cls .IOSettings .graphene_model .__class__ .__name__ , obj .id
245- )
281+ obj_id = to_global_id (graphene_model .__name__ , obj .id )
246282 logger .info ("Succeeded updating obj" )
247283
248284 else :
@@ -262,9 +298,7 @@ def mutate(cls, root, info, *args, **kwargs):
262298
263299 ok = True
264300 message = "Success"
265- obj_id = to_global_id (
266- cls .IOSettings .graphene_model .__class__ .__name__ , obj .id
267- )
301+ obj_id = to_global_id (graphene_model .__name__ , obj .id )
268302
269303 except serializers .ValidationError as ve :
270304 logger .warning (f"Validation error in mutation: { ve .detail } " )
0 commit comments