1<?php 2// autoassign.php -- HotCRP automatic paper assignment page 3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE. 4 5require_once("src/initweb.php"); 6if (!$Me->is_manager()) 7 $Me->escape(); 8 9// clean request 10 11// paper selection 12if (!isset($Qreq->q) || trim($Qreq->q) === "(All)") 13 $Qreq->q = ""; 14if ($Qreq->post_ok()) 15 header("X-Accel-Buffering: no"); // NGINX: do not hold on to file 16 17$tOpt = PaperSearch::manager_search_types($Me); 18if ($Me->privChair && !isset($Qreq->t) 19 && $Qreq->a === "prefconflict" 20 && $Conf->can_pc_see_all_submissions()) 21 $Qreq->t = "all"; 22if (!isset($Qreq->t) || !isset($tOpt[$Qreq->t])) { 23 reset($tOpt); 24 $Qreq->t = key($tOpt); 25} 26 27// PC selection 28$Qreq->allow_a("pcs", "pap", "p"); 29if (isset($Qreq->pcs) && is_string($Qreq->pcs)) 30 $Qreq->pcs = preg_split('/\s+/', $Qreq->pcs); 31if (isset($Qreq->pcs) && is_array($Qreq->pcs)) { 32 $pcsel = array(); 33 foreach ($Qreq->pcs as $p) 34 if (($p = cvtint($p)) > 0) 35 $pcsel[$p] = 1; 36} else 37 $pcsel = $Conf->pc_members(); 38 39if (!isset($Qreq->pctyp) 40 || ($Qreq->pctyp !== "all" && $Qreq->pctyp !== "sel")) 41 $Qreq->pctyp = "all"; 42 43// bad pairs 44// load defaults from last autoassignment or save entry to default 45if (!isset($Qreq->badpairs) && !isset($Qreq->assign) && $Qreq->method() !== "POST") { 46 $x = preg_split('/\s+/', $Conf->setting_data("autoassign_badpairs", ""), null, PREG_SPLIT_NO_EMPTY); 47 $pcm = $Conf->pc_members(); 48 $bpnum = 1; 49 for ($i = 0; $i < count($x) - 1; $i += 2) 50 if (isset($pcm[$x[$i]]) && isset($pcm[$x[$i+1]])) { 51 $Qreq["bpa$bpnum"] = $pcm[$x[$i]]->email; 52 $Qreq["bpb$bpnum"] = $pcm[$x[$i+1]]->email; 53 ++$bpnum; 54 } 55 if ($Conf->setting("autoassign_badpairs")) 56 $Qreq->badpairs = 1; 57} else if ($Me->privChair && isset($Qreq->assign) && $Qreq->post_ok()) { 58 $x = array(); 59 for ($i = 1; isset($Qreq["bpa$i"]); ++$i) 60 if ($Qreq["bpa$i"] && $Qreq["bpb$i"] 61 && ($pca = $Conf->pc_member_by_email($Qreq["bpa$i"])) 62 && ($pcb = $Conf->pc_member_by_email($Qreq["bpb$i"]))) { 63 $x[] = $pca->contactId; 64 $x[] = $pcb->contactId; 65 } 66 if (count($x) || $Conf->setting_data("autoassign_badpairs") 67 || (!isset($Qreq->badpairs) != !$Conf->setting("autoassign_badpairs"))) 68 $Conf->q("insert into Settings (name, value, data) values ('autoassign_badpairs', ?, ?) on duplicate key update data=values(data), value=values(value)", isset($Qreq->badpairs) ? 1 : 0, join(" ", $x)); 69} 70// set $badpairs array 71$badpairs = array(); 72if (isset($Qreq->badpairs)) 73 for ($i = 1; isset($Qreq["bpa$i"]); ++$i) 74 if ($Qreq["bpa$i"] && $Qreq["bpb$i"]) { 75 if (!isset($badpairs[$Qreq["bpa$i"]])) 76 $badpairs[$Qreq["bpa$i"]] = array(); 77 $badpairs[$Qreq["bpa$i"]][$Qreq["bpb$i"]] = 1; 78 } 79 80// paper selection 81if ((isset($Qreq->prevt) && isset($Qreq->t) && $Qreq->prevt !== $Qreq->t) 82 || (isset($Qreq->prevq) && isset($Qreq->q) && $Qreq->prevq !== $Qreq->q)) { 83 if (isset($Qreq->assign)) 84 $Conf->warnMsg("You changed the paper search. Please review the paper list."); 85 unset($Qreq->assign); 86 $Qreq->requery = 1; 87} 88 89if (isset($Qreq->saveassignment)) 90 $SSel = SearchSelection::make($Qreq, $Me, $Qreq->submit ? "pap" : "p"); 91else { 92 $SSel = new SearchSelection; 93 if (!$Qreq->requery) 94 $SSel = SearchSelection::make($Qreq, $Me); 95 if ($SSel->is_empty()) { 96 $search = new PaperSearch($Me, array("t" => $Qreq->t, "q" => $Qreq->q)); 97 $SSel = new SearchSelection($search->paper_ids()); 98 } 99} 100$SSel->sort_selection(); 101 102// rev_round 103if (($x = $Conf->sanitize_round_name($Qreq->rev_round)) !== false) 104 $Qreq->rev_round = $x; 105 106// score selector 107$scoreselector = array("+overAllMerit" => "", "-overAllMerit" => ""); 108foreach ($Conf->all_review_fields() as $f) 109 if ($f->has_options) { 110 $scoreselector["+" . $f->id] = "high $f->name_html scores"; 111 $scoreselector["-" . $f->id] = "low $f->name_html scores"; 112 } 113if ($scoreselector["+overAllMerit"] === "") 114 unset($scoreselector["+overAllMerit"], $scoreselector["-overAllMerit"]); 115$scoreselector["__break"] = null; 116$scoreselector["x"] = "random submitted reviews"; 117$scoreselector["xa"] = "random reviews"; 118 119// download proposed assignment 120if (isset($Qreq->saveassignment) 121 && isset($Qreq->download) 122 && isset($Qreq->assignment)) { 123 $assignset = new AssignmentSet($Me, true); 124 $assignset->parse($Qreq->assignment); 125 $x = $assignset->unparse_csv(); 126 csv_exit($Conf->make_csvg("assignments")->select($x->header) 127 ->add($x->data)->sort(SORT_NATURAL)); 128} 129 130 131$Conf->header("Assignments ∕ <strong>Automatic</strong>", "autoassign"); 132echo '<div class="psmode">', 133 '<div class="papmodex"><a href="', hoturl("autoassign"), '">Automatic</a></div>', 134 '<div class="papmode"><a href="', hoturl("manualassign"), '">Manual</a></div>', 135 '<div class="papmode"><a href="', hoturl("conflictassign"), '">Conflicts</a></div>', 136 '<div class="papmode"><a href="', hoturl("bulkassign"), '">Bulk update</a></div>', 137 '</div><hr class="c" />'; 138 139 140class AutoassignerInterface { 141 private $conf; 142 private $user; 143 private $qreq; 144 private $atype; 145 private $atype_review; 146 private $reviewtype; 147 private $reviewcount; 148 private $reviewround; 149 private $discordertag; 150 private $autoassigner; 151 private $start_at; 152 private $live; 153 public $ok = false; 154 public $errors = []; 155 156 static function current_costs(Conf $conf, $qreq) { 157 $costs = new AutoassignerCosts; 158 if (($x = $conf->opt("autoassignCosts")) 159 && ($x = json_decode($x)) 160 && is_object($x)) 161 $costs = $x; 162 foreach (get_object_vars($costs) as $k => $v) 163 if ($qreq && isset($qreq["{$k}_cost"]) 164 && ($v = cvtint($qreq["{$k}_cost"], null)) !== null) 165 $costs->$k = $v; 166 return $costs; 167 } 168 169 function __construct(Contact $user, Qrequest $qreq) { 170 $this->conf = $user->conf; 171 $this->user = $user; 172 $this->qreq = $qreq; 173 174 $atypes = array("rev" => "r", "revadd" => "r", "revpc" => "r", 175 "lead" => true, "shepherd" => true, 176 "prefconflict" => true, "clear" => true, 177 "discorder" => true, "" => null); 178 $this->atype = $qreq->a; 179 if (!$this->atype || !isset($atypes[$this->atype])) { 180 $this->errors["ass"] = "Malformed request!"; 181 $this->atype = ""; 182 } 183 $this->atype_review = $atypes[$this->atype] === "r"; 184 185 $r = false; 186 if ($this->atype_review) { 187 $r = $qreq[$this->atype . "type"]; 188 if ($r != REVIEW_META && $r != REVIEW_PRIMARY 189 && $r != REVIEW_SECONDARY && $r != REVIEW_PC) 190 $this->errors["ass"] = "Malformed request!"; 191 } else if ($this->atype === "clear") { 192 $r = $qreq->cleartype; 193 if ($r != REVIEW_META && $r != REVIEW_PRIMARY 194 && $r != REVIEW_SECONDARY && $r != REVIEW_PC 195 && $r !== "conflict" 196 && $r !== "lead" && $r !== "shepherd") 197 $this->errors["a-clear"] = "Malformed request!"; 198 } 199 $this->reviewtype = $r; 200 201 if ($this->atype_review) { 202 $this->reviewcount = cvtint($qreq[$this->atype . "ct"], -1); 203 if ($this->reviewcount <= 0) 204 $this->errors[$this->atype . "ct"] = "You must assign at least one review."; 205 206 $this->reviewround = $qreq->rev_round; 207 if ($this->reviewround !== "" 208 && ($err = Conf::round_name_error($this->reviewround))) 209 $this->errors["rev_round"] = $err; 210 } 211 212 if ($this->atype === "discorder") { 213 $tag = trim((string) $qreq->discordertag); 214 $tag = $tag === "" ? "discuss" : $tag; 215 $tagger = new Tagger; 216 if (($tag = $tagger->check($tag, Tagger::NOVALUE))) 217 $this->discordertag = $tag; 218 else 219 $this->errors["discordertag"] = $tagger->error_html; 220 } 221 222 $this->ok = empty($this->errors); 223 } 224 225 function check() { 226 foreach ($this->errors as $etype => $msg) { 227 Conf::msg_error($msg); 228 Ht::error_at($etype); 229 } 230 return $this->ok; 231 } 232 233 private function result_html() { 234 global $SSel, $pcsel; 235 $assignments = $this->autoassigner->assignments(); 236 Review_Assigner::$prefinfo = $this->autoassigner->prefinfo; 237 ob_start(); 238 239 if (!$assignments) { 240 Conf::msg_warning("Nothing to assign."); 241 return ob_get_clean(); 242 } 243 244 $assignset = new AssignmentSet($this->user, true); 245 $assignset->set_search_type($this->qreq->t); 246 $assignset->parse(join("\n", $assignments)); 247 248 $atypes = $assignset->assigned_types(); 249 $apids = $assignset->assigned_pids(true); 250 $badpairs_inputs = $badpairs_arg = array(); 251 for ($i = 1; isset($this->qreq["bpa$i"]); ++$i) 252 if ($this->qreq["bpa$i"] && $this->qreq["bpb$i"]) { 253 array_push($badpairs_inputs, Ht::hidden("bpa$i", $this->qreq["bpa$i"]), 254 Ht::hidden("bpb$i", $this->qreq["bpb$i"])); 255 $badpairs_arg[] = $this->qreq["bpa$i"] . "-" . $this->qreq["bpb$i"]; 256 } 257 echo Ht::form(hoturl_post("autoassign", 258 ["saveassignment" => 1, 259 "assigntypes" => join(" ", $atypes), 260 "assignpids" => join(" ", $apids), 261 "xbadpairs" => count($badpairs_arg) ? join(" ", $badpairs_arg) : null, 262 "profile" => $this->qreq->profile, 263 "XDEBUG_PROFILE" => $this->qreq->XDEBUG_PROFILE, 264 "seed" => $this->qreq->seed])); 265 266 $atype = $assignset->type_description(); 267 echo "<h3>Proposed " . ($atype ? $atype . " " : "") . "assignment</h3>"; 268 Conf::msg_info("Select “Apply changes” if this looks OK. (You can always alter the assignment afterwards.) Reviewer preferences, if any, are shown as “P#”."); 269 $assignset->report_errors(); 270 $assignset->echo_unparse_display(); 271 272 // print preference unhappiness 273 if ($this->qreq->profile && $this->atype_review) { 274 $umap = $this->autoassigner->pc_unhappiness(); 275 sort($umap); 276 echo '<p style="font-size:65%">Preference unhappiness: '; 277 $usum = 0; 278 foreach ($umap as $u) 279 $usum += $u; 280 if (count($umap) % 2 == 0) 281 $umedian = ($umap[count($umap) / 2 - 1] + $umap[count($umap) / 2]) / 2; 282 else 283 $umedian = $umap[(count($umap) - 1) / 2]; 284 echo 'mean ', sprintf("%.2f", $usum / count($umap)), 285 ', min ', $umap[0], 286 ', 10% ', $umap[(int) (count($umap) * 0.1)], 287 ', 25% ', $umap[(int) (count($umap) * 0.25)], 288 ', median ', $umedian, 289 ', 75% ', $umap[(int) (count($umap) * 0.75)], 290 ', 90% ', $umap[(int) (count($umap) * 0.9)], 291 ', max ', $umap[count($umap) - 1], 292 '<br/>Time: ', sprintf("%.6f", microtime(true) - $this->start_at); 293 foreach ($this->autoassigner->profile as $name => $time) 294 echo ', ', sprintf("%s %.6f", htmlspecialchars($name), $time); 295 echo '</p>'; 296 } 297 298 echo '<div class="aab aabig btnp">', 299 Ht::submit("submit", "Apply changes", ["class" => "btn btn-primary"]), 300 Ht::submit("download", "Download assignment file", ["class" => "btn"]), 301 Ht::submit("cancel", "Cancel", ["class" => "btn"]); 302 foreach (array("t", "q", "a", "revtype", "revaddtype", "revpctype", "cleartype", "revct", "revaddct", "revpcct", "pctyp", "balance", "badpairs", "rev_round", "method", "haspap") as $t) 303 if (isset($this->qreq[$t])) 304 echo Ht::hidden($t, $this->qreq[$t]); 305 echo Ht::hidden("pcs", join(" ", array_keys($pcsel))), 306 join("", $badpairs_inputs), 307 Ht::hidden("p", join(" ", $SSel->selection())), "\n"; 308 309 // save the assignment 310 echo Ht::hidden("assignment", join("\n", $assignments)), "\n"; 311 312 echo "</div></form>"; 313 return ob_get_clean(); 314 } 315 316 function progress($status) { 317 if ($this->live && microtime(true) - $this->start_at > 1) { 318 $this->live = false; 319 echo "</div>\n", Ht::unstash(); 320 } 321 if (!$this->live) { 322 $t = '<h3>Preparing assignment</h3><p><strong>Status:</strong> ' . htmlspecialchars($status); 323 echo Ht::script('$$("propass").innerHTML=' . json_encode_browser($t) . ';'), "\n"; 324 flush(); 325 while (@ob_end_flush()) 326 /* skip */; 327 } 328 } 329 330 function run() { 331 global $SSel, $pcsel, $badpairs; 332 assert($this->ok); 333 session_write_close(); // this might take a long time 334 set_time_limit(240); 335 336 // prepare autoassigner 337 if ($this->qreq->seed && is_numeric($this->qreq->seed)) 338 srand((int) $this->qreq->seed); 339 $this->autoassigner = $autoassigner = new Autoassigner($this->conf, $SSel->selection()); 340 if ($this->qreq->pctyp === "sel") { 341 $n = $autoassigner->select_pc(array_keys($pcsel)); 342 if ($n == 0) { 343 Conf::msg_error("Select one or more PC members to assign."); 344 return null; 345 } 346 } 347 if ($this->qreq->balance === "all") 348 $autoassigner->set_balance(Autoassigner::BALANCE_ALL); 349 foreach ($badpairs as $cid1 => $bp) { 350 foreach ($bp as $cid2 => $x) 351 $autoassigner->avoid_pair_assignment($cid1, $cid2); 352 } 353 if ($this->qreq->method === "random") 354 $autoassigner->set_method(Autoassigner::METHOD_RANDOM); 355 else 356 $autoassigner->set_method(Autoassigner::METHOD_MCMF); 357 if ($this->conf->opt("autoassignReviewGadget") === "expertise") 358 $autoassigner->set_review_gadget(Autoassigner::REVIEW_GADGET_EXPERTISE); 359 // save costs 360 $autoassigner->costs = self::current_costs($this->conf, $this->qreq); 361 $costs_json = json_encode($autoassigner->costs); 362 if ($costs_json !== $this->conf->opt("autoassignCosts")) { 363 if ($costs_json === json_encode(new AutoassignerCosts)) 364 $this->conf->save_setting("opt.autoassignCosts", null); 365 else 366 $this->conf->save_setting("opt.autoassignCosts", 1, $costs_json); 367 } 368 $autoassigner->add_progressf([$this, "progress"]); 369 $this->live = true; 370 echo '<div id="propass" class="propass">'; 371 372 $this->start_at = microtime(true); 373 if ($this->atype === "prefconflict") 374 $autoassigner->run_prefconflict($this->qreq->t); 375 else if ($this->atype === "clear") 376 $autoassigner->run_clear($this->reviewtype); 377 else if ($this->atype === "lead" || $this->atype === "shepherd") 378 $autoassigner->run_paperpc($this->atype, $this->qreq["{$this->atype}score"]); 379 else if ($this->atype === "revpc") 380 $autoassigner->run_reviews_per_pc($this->reviewtype, $this->reviewround, $this->reviewcount); 381 else if ($this->atype === "revadd") 382 $autoassigner->run_more_reviews($this->reviewtype, $this->reviewround, $this->reviewcount); 383 else if ($this->atype === "rev") 384 $autoassigner->run_ensure_reviews($this->reviewtype, $this->reviewround, $this->reviewcount); 385 else if ($this->atype === "discorder") 386 $autoassigner->run_discussion_order($this->discordertag); 387 388 if ($this->live) 389 echo $this->result_html(), "</div>\n"; 390 else { 391 PaperList::$include_stash = false; 392 $result_html = $this->result_html(); 393 echo Ht::unstash_script('$$("propass").innerHTML=' . json_encode($result_html)), "\n"; 394 } 395 if ($this->autoassigner->assignments()) { 396 $this->conf->footer(); 397 exit; 398 } 399 } 400} 401 402if (isset($Qreq->assign) && isset($Qreq->a) 403 && isset($Qreq->pctyp) && $Qreq->post_ok()) { 404 $ai = new AutoassignerInterface($Me, $Qreq); 405 if ($ai->check()) 406 $ai->run(); 407 ensure_session(); 408} else if ($Qreq->saveassignment && $Qreq->submit 409 && isset($Qreq->assignment) && $Qreq->post_ok()) { 410 $assignset = new AssignmentSet($Me, true); 411 $assignset->enable_papers($SSel->selection()); 412 $assignset->parse($Qreq->assignment); 413 $assignset->execute(true); 414} 415 416 417function echo_radio_row($name, $value, $text, $extra = null) { 418 global $Qreq; 419 if (($checked = (!isset($Qreq[$name]) || $Qreq[$name] === $value))) 420 $Qreq[$name] = $value; 421 $extra = ($extra ? $extra : array()); 422 $extra["id"] = "${name}_$value"; 423 $is_open = get($extra, "open"); 424 unset($extra["open"]); 425 $k = Ht::control_class("{$name}-{$value}"); 426 echo '<tr class="js-radio-focus', $k, '"><td class="nw">', 427 Ht::radio($name, $value, $checked, $extra), " </td><td>"; 428 if ($text !== "") 429 echo Ht::label($text, "${name}_$value"); 430 if (!$is_open) 431 echo "</td></tr>\n"; 432} 433 434function doSelect($name, $opts, $extra = null) { 435 global $Qreq; 436 if (!isset($Qreq[$name])) 437 $Qreq[$name] = key($opts); 438 echo Ht::select($name, $opts, $Qreq[$name], $extra); 439} 440 441function divClass($name, $classes = null) { 442 if (($c = Ht::control_class($name, $classes))) 443 return '<div class="' . $c . '">'; 444 else 445 return '<div>'; 446} 447 448echo Ht::form(hoturl_post("autoassign", array("profile" => $Qreq->profile, "seed" => $Qreq->seed, "XDEBUG_PROFILE" => $Qreq->XDEBUG_PROFILE)), ["id" => "autoassignform"]), 449 "<div class='helpside'><div class='helpinside'> 450Assignment methods: 451<ul><li><a href='", hoturl("autoassign"), "' class='q'><strong>Automatic</strong></a></li> 452 <li><a href=\"", hoturl("manualassign"), "\">Manual by PC member</a></li> 453 <li><a href=\"", hoturl("assign") . "\">Manual by paper</a></li> 454 <li><a href=\"", hoturl("conflictassign"), "\">Potential conflicts</a></li> 455 <li><a href=\"", hoturl("bulkassign"), "\">Bulk update</a></li> 456</ul> 457<hr class='hr' /> 458Types of PC review: 459<dl><dt>" . review_type_icon(REVIEW_PRIMARY) . " Primary</dt><dd>Mandatory review</dd> 460 <dt>" . review_type_icon(REVIEW_SECONDARY) . " Secondary</dt><dd>May be delegated to external reviewers</dd> 461 <dt>" . review_type_icon(REVIEW_PC) . " Optional</dt><dd>May be declined</dd> 462 <dt>" . review_type_icon(REVIEW_META) . " Metareview</dt><dd>Can view all other reviews before completing their own</dd></dl> 463</div></div>\n"; 464echo Ht::unstash_script("hiliter_children(\"#autoassignform\")"); 465 466// paper selection 467echo divClass("pap"), "<h3>Paper selection</h3>"; 468if (!isset($Qreq->q)) // XXX redundant 469 $Qreq->q = join(" ", $SSel->selection()); 470echo Ht::entry("q", $Qreq->q, 471 array("id" => "autoassignq", "placeholder" => "(All)", 472 "size" => 40, "title" => "Enter paper numbers or search terms", 473 "class" => Ht::control_class("q", "papersearch js-autosubmit"), 474 "data-autosubmit-type" => "requery")), " in "; 475if (count($tOpt) > 1) 476 echo Ht::select("t", $tOpt, $Qreq->t); 477else 478 echo join("", $tOpt); 479echo " ", Ht::submit("requery", "List", ["id" => "requery", "class" => "btn"]); 480if (isset($Qreq->requery) || isset($Qreq->haspap)) { 481 $search = new PaperSearch($Me, array("t" => $Qreq->t, "q" => $Qreq->q, 482 "urlbase" => hoturl_site_relative_raw("autoassign"))); 483 $plist = new PaperList($search, ["display" => "show:reviewers"]); 484 $plist->set_selection($SSel); 485 486 if ($search->paper_ids()) 487 echo "<br /><span class='hint'>Assignments will apply to the selected papers.</span>"; 488 489 echo '<div class="g"></div>'; 490 echo $plist->table_html("reviewersSel", ["nofooter" => true]), 491 Ht::hidden("prevt", $Qreq->t), Ht::hidden("prevq", $Qreq->q), 492 Ht::hidden("haspap", 1); 493} 494echo "</div>\n"; 495 496 497// action 498echo '<div>'; 499echo divClass("ass"), "<h3>Action</h3>", "</div>"; 500echo '<table>'; 501echo_radio_row("a", "rev", "Ensure each selected paper has <i>at least</i>", ["open" => true]); 502echo " ", 503 Ht::entry("revct", get($Qreq, "revct", 1), 504 ["size" => 3, "class" => Ht::control_class("revct", "js-autosubmit")]), " "; 505doSelect("revtype", array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview")); 506echo " review(s)</td></tr>\n"; 507 508echo_radio_row("a", "revadd", "Assign", ["open" => true]); 509echo " ", 510 Ht::entry("revaddct", get($Qreq, "revaddct", 1), 511 ["size" => 3, "class" => Ht::control_class("revaddct", "js-autosubmit")]), 512 " <i>additional</i> "; 513doSelect("revaddtype", array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview")); 514echo " review(s) per selected paper</td></tr>\n"; 515 516echo_radio_row("a", "revpc", "Assign each PC member", ["open" => true]); 517echo " ", 518 Ht::entry("revpcct", get($Qreq, "revpcct", 1), 519 ["size" => 3, "class" => Ht::control_class("revpcct", "js-autosubmit")]), 520 " additional "; 521doSelect("revpctype", array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview")); 522echo " review(s) from this paper selection</td></tr>\n"; 523 524// Review round 525$rev_rounds = $Conf->round_selector_options(null); 526if (count($rev_rounds) > 1 || !get($rev_rounds, "unnamed")) { 527 echo '<tr><td></td><td'; 528 if (($c = Ht::control_class("rev_round"))) 529 echo ' class="', trim($c), '"'; 530 echo ' style="font-size:smaller">Review round: '; 531 if (count($rev_rounds) > 1) 532 echo ' ', Ht::select("rev_round", $rev_rounds, $Qreq->rev_round ? : "unnamed"); 533 else 534 echo $Qreq->rev_round ? : "unnamed"; 535 echo "</td></tr>\n"; 536} 537 538// gap 539echo '<tr><td colspan="2" class="mg"></td></tr>'; 540 541// conflicts, leads, shepherds 542echo_radio_row("a", "prefconflict", "Assign conflicts when PC members have review preferences of −100 or less"); 543 544echo_radio_row("a", "lead", "Assign discussion lead from reviewers, preferring ", ["open" => true]); 545doSelect('leadscore', $scoreselector); 546echo "</td></tr>\n"; 547 548echo_radio_row("a", "shepherd", "Assign shepherd from reviewers, preferring ", ["open" => true]); 549doSelect('shepherdscore', $scoreselector); 550echo "</td></tr>\n"; 551 552// gap 553echo '<tr><td colspan="2" class="mg"></td></tr>'; 554 555// clear assignments 556echo_radio_row("a", "clear", "Clear all ", ["open" => true]); 557doSelect('cleartype', array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview", "conflict" => "conflict", "lead" => "discussion lead", "shepherd" => "shepherd")); 558echo " assignments for selected papers and PC members</td></tr>\n"; 559 560// gap 561echo '<tr><td colspan="2" class="mg"></td></tr>'; 562 563// discussion order 564echo_radio_row("a", "discorder", "Create discussion order in tag #", ["open" => true]); 565echo Ht::entry("discordertag", get($Qreq, "discordertag", "discuss"), 566 ["size" => 12, "class" => Ht::control_class("discordertag", "js-autosubmit")]), 567 ", grouping papers with similar PC conflicts</td></tr>"; 568 569echo "</table>\n"; 570 571 572// PC 573echo "<h3>PC members</h3>\n<table>\n"; 574 575echo_radio_row("pctyp", "all", "Use entire PC"); 576 577echo_radio_row("pctyp", "sel", "Use selected PC members:", ["open" => true]); 578echo " (select "; 579$pctyp_sel = array(array("all", "all"), array("none", "none")); 580$pctags = $Conf->pc_tags(); 581if (!empty($pctags)) { 582 $tagsjson = array(); 583 foreach ($Conf->pc_members() as $pc) 584 $tagsjson[$pc->contactId] = " " . trim(strtolower($pc->viewable_tags($Me))) . " "; 585 Ht::stash_script("var hotcrp_pc_tags=" . json_encode($tagsjson) . ";"); 586 foreach ($pctags as $tagname => $pctag) 587 if ($tagname !== "pc" && $Conf->tags()->strip_nonviewable($tagname, $Me, null)) 588 $pctyp_sel[] = [$pctag, "#$pctag"]; 589} 590$pctyp_sel[] = array("__flip__", "flip"); 591$sep = ""; 592foreach ($pctyp_sel as $pctyp) { 593 echo $sep, "<a class=\"ui js-pcsel-tag\" href=\"#pc_", $pctyp[0], "\">", $pctyp[1], "</a>"; 594 $sep = ", "; 595} 596echo ")"; 597Ht::stash_script('function make_pcsel_members(tag) { 598 if (tag === "__flip__") 599 return function () { return !this.checked; }; 600 else if (tag === "all") 601 return function () { return true; }; 602 else if (tag === "none") 603 return function () { return false; }; 604 else { 605 tag = " " + tag.toLowerCase() + "#"; 606 return function () { 607 var tlist = hotcrp_pc_tags[this.value] || ""; 608 return tlist.indexOf(tag) >= 0; 609 }; 610 } 611} 612function pcsel_tag(event) { 613 var $g = $(this).closest(".js-radio-focus"), e; 614 if (this.tagName === "A") { 615 $g.find("input[type=radio]").first().click(); 616 var f = make_pcsel_members(this.hash.substring(4)); 617 $g.find("input").each(function () { 618 if (this.name === "pcs[]") 619 this.checked = f.call(this); 620 }); 621 event_prevent(event); 622 } 623 var tags = [], functions = {}; 624 $g.find("a.js-pcsel-tag").each(function () { 625 var tag = this.hash.substring(4); 626 tags.push(tag); 627 functions[tag] = make_pcsel_members(tag); 628 }); 629 $g.find("input").each(function () { 630 if (this.name === "pcs[]") { 631 for (var i = 0; i < tags.length; ) { 632 if (this.checked !== functions[tags[i]].call(this)) 633 tags.splice(i, 1); 634 else 635 ++i; 636 } 637 } 638 }); 639 $g.find("a.js-pcsel-tag").each(function () { 640 if ($.inArray(this.hash.substring(4), tags) >= 0) 641 $(this).css("font-weight", "bold"); 642 else 643 $(this).css("font-weight", "inherit"); 644 }); 645} 646$(document).on("click", "a.js-pcsel-tag", pcsel_tag); 647$(document).on("change", "input.js-pcsel-tag", pcsel_tag); 648$(function(){$("input.js-pcsel-tag").first().trigger("change")})'); 649 650$summary = []; 651$tagger = new Tagger($Me); 652$nrev = new AssignmentCountSet($Conf); 653$nrev->load_rev(); 654foreach ($Conf->pc_members() as $id => $p) { 655 $t = '<div class="ctelt"><label class="ctelti checki'; 656 if (($k = $p->viewable_color_classes($Me))) 657 $t .= ' ' . $k; 658 $t .= '"><span class="checkc">' 659 . Ht::checkbox("pcs[]", $id, isset($pcsel[$id]), 660 ["id" => "pcc$id", "class" => "uix js-range-click js-pcsel-tag"]) 661 . ' </span>' 662 . '<span class="taghl">' . $Me->name_html_for($p) . '</span>' 663 . AssignmentSet::review_count_report($nrev, null, $p, "") 664 . "</label></div>"; 665 $summary[] = $t; 666} 667echo '<div class="pc_ctable" style="margin-top:0.5em">', join("", $summary), "</div>\n", 668 "</td></tr></table>\n"; 669 670 671// Bad pairs 672function bpSelector($i, $which) { 673 global $Qreq; 674 return Ht::select("bp$which$i", [], 0, 675 ["class" => "need-pcselector badpairs", "data-pcselector-selected" => $Qreq["bp$which$i"], "data-pcselector-options" => "[\"(PC member)\",\"*\"]", "data-default-value" => $Qreq["bp$which$i"]]); 676} 677 678echo "<div class='g'></div><div class='relative'><table id=\"bptable\"><tbody>\n"; 679for ($i = 1; $i == 1 || isset($Qreq["bpa$i"]); ++$i) { 680 $selector_text = bpSelector($i, "a") . " and " . bpSelector($i, "b"); 681 echo ' <tr><td class="rentry nw">'; 682 if ($i == 1) 683 echo Ht::checkbox("badpairs", 1, isset($Qreq["badpairs"]), 684 array("id" => "badpairs")), 685 " ", Ht::label("Don’t assign", "badpairs"), " "; 686 else 687 echo "or "; 688 echo '</td><td class="lentry">', $selector_text; 689 if ($i == 1) 690 echo ' to the same paper (<a class="ui js-badpairs-row more" href="#">More</a> · <a class="ui js-badpairs-row less" href="#">Fewer</a>)'; 691 echo "</td></tr>\n"; 692} 693echo "</tbody></table></div>\n"; 694$Conf->stash_hotcrp_pc($Me); 695echo Ht::unstash_script('$("#bptable").on("change", "select.badpairs", function () { 696 if (this.value !== "none") { 697 var x = $$("badpairs"); 698 x.checked || x.click(); 699 } 700}); 701$("#bptable a.js-badpairs-row").on("click", function () { 702 var tbody = $("#bptable > tbody"), n = tbody.children().length; 703 if (hasClass(this, "more")) { 704 ++n; 705 tbody.append(\'<tr><td class="rentry nw">or </td><td class="lentry"><select name="bpa\' + n + \'" class="badpairs"></select> and <select name="bpb\' + n + \'" class="badpairs"></select></td></tr>\'); 706 var options = tbody.find("select").first().html(); 707 tbody.find("select[name=bpa" + n + "], select[name=bpb" + n + "]").html(options).val("none"); 708 } else if (n > 1) { 709 --n; 710 tbody.children().last().remove(); 711 } 712 return false; 713}); 714$(".need-pcselector").each(populate_pcselector)'); 715 716 717// Load balancing 718echo "<h3>Load balancing</h3>\n<table>\n"; 719echo_radio_row("balance", "new", "New assignments—spread new assignments equally among selected PC members"); 720echo_radio_row("balance", "all", "All assignments—spread assignments so that selected PC members have roughly equal overall load"); 721echo "</table>\n"; 722 723 724// Method 725echo "<h3>Assignment method</h3>\n<table>\n"; 726echo_radio_row("method", "mcmf", "Globally optimal assignment"); 727echo_radio_row("method", "random", "Random good assignment"); 728echo "</table>\n"; 729 730if ($Conf->opt("autoassignReviewGadget") === "expertise") { 731 echo "<div><strong>Costs:</strong> "; 732 $costs = AutoassignerInterface::current_costs($Conf, $Qreq); 733 foreach (get_object_vars($costs) as $k => $v) 734 echo '<span style="display:inline-block;margin-right:2em">', 735 Ht::label($k, "{$k}_cost"), 736 " ", Ht::entry("{$k}_cost", $v, ["size" => 4]), 737 '</span>'; 738 echo "</div>\n"; 739} 740 741 742// Create assignment 743echo '<div class="aab aabig">', Ht::submit("assign", "Prepare assignments", ["class" => "btn btn-primary"]), 744 ' <span class="hint">You’ll be able to check the assignment before it is saved.</span>', 745 '</div>'; 746 747echo "</div></form>"; 748 749$Conf->footer(); 750