@@ -290,3 +290,84 @@ async def get_character_collaborations(
290290 "limit" : limit ,
291291 ** _serialize_visible_matrix (pairs , limit ),
292292 }
293+
294+
295+ @router .get ("/creator-character-collaborations" , name = "creator_character_collaborations" )
296+ async def get_creator_character_collaborations (
297+ db : SessionDep ,
298+ current_user : CurrentUser ,
299+ role_a : CreatorRole = Query ("writer" ),
300+ library_id : Optional [int ] = Query (None , ge = 1 ),
301+ min_shared : int = Query (2 , ge = 1 , le = 100 ),
302+ limit : int = Query (12 , ge = 2 , le = 15 , description = "Maximum creators and characters shown per axis" ),
303+ ):
304+ """
305+ Aggregates creator-to-character pairings for heatmap-style insights.
306+ """
307+ if library_id is not None :
308+ _ensure_library_access (library_id , current_user )
309+
310+ credit_a = aliased (ComicCredit )
311+ person_a = aliased (Person )
312+ char_link_b = comic_characters .alias ("char_link_b" )
313+ character_b = aliased (Character )
314+
315+ shared_issue_count = func .count (func .distinct (Comic .id ))
316+ shared_series_count = func .count (func .distinct (Series .id ))
317+
318+ query = (
319+ db .query (
320+ person_a .id .label ("person_a_id" ),
321+ person_a .name .label ("person_a" ),
322+ character_b .id .label ("person_b_id" ),
323+ character_b .name .label ("person_b" ),
324+ shared_issue_count .label ("shared_issues" ),
325+ shared_series_count .label ("shared_series" ),
326+ func .min (Series .name ).label ("sample_series" ),
327+ )
328+ .select_from (Comic )
329+ .join (Volume , Comic .volume_id == Volume .id )
330+ .join (Series , Volume .series_id == Series .id )
331+ .join (credit_a , credit_a .comic_id == Comic .id )
332+ .join (person_a , person_a .id == credit_a .person_id )
333+ .join (char_link_b , char_link_b .c .comic_id == Comic .id )
334+ .join (character_b , character_b .id == char_link_b .c .character_id )
335+ .filter (credit_a .role == role_a )
336+ )
337+
338+ if not current_user .is_superuser :
339+ allowed_ids = [lib .id for lib in current_user .accessible_libraries ]
340+ query = query .filter (Series .library_id .in_ (allowed_ids ))
341+
342+ if library_id is not None :
343+ query = query .filter (Series .library_id == library_id )
344+
345+ age_filter = get_series_age_restriction (current_user )
346+ if age_filter is not None :
347+ query = query .filter (age_filter )
348+
349+ pair_cap = max (limit * limit * 2 , 50 )
350+
351+ pairs = (
352+ query .group_by (person_a .id , person_a .name , character_b .id , character_b .name )
353+ .having (shared_issue_count >= min_shared )
354+ .order_by (desc ("shared_issues" ), person_a .name .asc (), character_b .name .asc ())
355+ .limit (pair_cap )
356+ .all ()
357+ )
358+
359+ if not pairs :
360+ return _empty_matrix_payload (
361+ role_a = role_a ,
362+ library_id = library_id ,
363+ min_shared = min_shared ,
364+ limit = limit ,
365+ )
366+
367+ return {
368+ "role_a" : role_a ,
369+ "library_id" : library_id ,
370+ "min_shared" : min_shared ,
371+ "limit" : limit ,
372+ ** _serialize_visible_matrix (pairs , limit ),
373+ }
0 commit comments