1<?php 2// assignmentset.php -- HotCRP helper classes for assignments 3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE. 4 5class AssignmentItem implements ArrayAccess { 6 public $before; 7 public $after = null; 8 public $lineno = null; 9 function __construct($before) { 10 $this->before = $before; 11 } 12 function offsetExists($offset) { 13 $x = $this->after ? : $this->before; 14 return isset($x[$offset]); 15 } 16 function offsetGet($offset) { 17 $x = $this->after ? : $this->before; 18 return isset($x[$offset]) ? $x[$offset] : null; 19 } 20 function offsetSet($offset, $value) { 21 } 22 function offsetUnset($offset) { 23 } 24 function existed() { 25 return !!$this->before; 26 } 27 function deleted() { 28 return $this->after === false; 29 } 30 function modified() { 31 return $this->after !== null; 32 } 33 function get($before, $offset = null) { 34 if ($offset === null) 35 return $this->offsetGet($before); 36 if ($before || $this->after === null) 37 $x = $this->before; 38 else 39 $x = $this->after; 40 return $x && isset($x[$offset]) ? $x[$offset] : null; 41 } 42 function get_before($offset) { 43 return $this->get(true, $offset); 44 } 45 function differs($offset) { 46 return $this->get(true, $offset) !== $this->get(false, $offset); 47 } 48 function realize(AssignmentState $astate) { 49 return call_user_func($astate->realizer($this->offsetGet("type")), $this, $astate); 50 } 51} 52 53class AssignmentState { 54 private $st = array(); 55 private $types = array(); 56 private $realizers = []; 57 public $conf; 58 public $user; // executor 59 public $reviewer; // default contact 60 public $overrides = 0; 61 private $cmap; 62 private $reviewer_users = null; 63 public $lineno = null; 64 public $defaults = array(); 65 private $prows = array(); 66 private $pid_attempts = array(); 67 public $finishers = array(); 68 public $paper_exact_match = true; 69 public $errors = []; 70 71 function __construct(Contact $user) { 72 $this->conf = $user->conf; 73 $this->user = $this->reviewer = $user; 74 $this->cmap = new AssignerContacts($this->conf, $this->user); 75 } 76 77 function mark_type($type, $keys, $realizer) { 78 if (!isset($this->types[$type])) { 79 $this->types[$type] = $keys; 80 $this->realizers[$type] = $realizer; 81 return true; 82 } else 83 return false; 84 } 85 function realizer($type) { 86 return $this->realizers[$type]; 87 } 88 private function pidstate($pid) { 89 if (!isset($this->st[$pid])) 90 $this->st[$pid] = (object) array("items" => array()); 91 return $this->st[$pid]; 92 } 93 private function extract_key($x, $pid = null) { 94 $tkeys = $this->types[$x["type"]]; 95 assert($tkeys); 96 $t = $x["type"]; 97 foreach ($tkeys as $k) 98 if (isset($x[$k])) 99 $t .= "`" . $x[$k]; 100 else if ($pid !== null && $k === "pid") 101 $t .= "`" . $pid; 102 else 103 return false; 104 return $t; 105 } 106 function load($x) { 107 $st = $this->pidstate($x["pid"]); 108 $k = $this->extract_key($x); 109 assert($k && !isset($st->items[$k])); 110 $st->items[$k] = new AssignmentItem($x); 111 $st->sorted = false; 112 } 113 114 private function pid_keys($q) { 115 if (isset($q["pid"])) 116 return array($q["pid"]); 117 else 118 return array_keys($this->st); 119 } 120 static private function match($x, $q) { 121 foreach ($q as $k => $v) { 122 if ($v !== null && get($x, $k) !== $v) 123 return false; 124 } 125 return true; 126 } 127 function query_items($q) { 128 $res = []; 129 foreach ($this->pid_keys($q) as $pid) { 130 $st = $this->pidstate($pid); 131 $k = $this->extract_key($q, $pid); 132 foreach ($k ? [get($st->items, $k)] : $st->items as $item) 133 if ($item && !$item->deleted() 134 && self::match($item->after ? : $item->before, $q)) 135 $res[] = $item; 136 } 137 return $res; 138 } 139 function query($q) { 140 $res = []; 141 foreach ($this->query_items($q) as $item) 142 $res[] = $item->after ? : $item->before; 143 return $res; 144 } 145 function query_unmodified($q) { 146 $res = []; 147 foreach ($this->query_items($q) as $item) 148 if (!$item->modified()) 149 $res[] = $item->before; 150 return $res; 151 } 152 function make_filter($key, $q) { 153 $cf = []; 154 foreach ($this->query($q) as $m) 155 $cf[$m[$key]] = true; 156 return $cf; 157 } 158 159 function remove($q) { 160 $res = []; 161 foreach ($this->query_items($q) as $item) { 162 $res[] = $item->after ? : $item->before; 163 $item->after = false; 164 $item->lineno = $this->lineno; 165 } 166 return $res; 167 } 168 function add($x) { 169 $k = $this->extract_key($x); 170 assert(!!$k); 171 $st = $this->pidstate($x["pid"]); 172 if (!($item = get($st->items, $k))) 173 $item = $st->items[$k] = new AssignmentItem(false); 174 $item->after = $x; 175 $item->lineno = $this->lineno; 176 return $item; 177 } 178 179 function diff() { 180 $diff = array(); 181 foreach ($this->st as $pid => $st) { 182 foreach ($st->items as $item) 183 if ((!$item->before && $item->after) 184 || ($item->before && $item->after === false) 185 || ($item->before && $item->after && !self::match($item->before, $item->after))) 186 $diff[$pid][] = $item; 187 } 188 return $diff; 189 } 190 191 function paper_ids() { 192 return array_keys($this->prows); 193 } 194 function prow($pid) { 195 $p = get($this->prows, $pid); 196 if (!$p && !isset($this->pid_attempts[$pid])) { 197 $this->fetch_prows($pid); 198 $p = get($this->prows, $pid); 199 } 200 return $p; 201 } 202 function add_prow(PaperInfo $prow) { 203 $this->prows[$prow->paperId] = $prow; 204 } 205 function prows() { 206 return $this->prows; 207 } 208 function fetch_prows($pids, $initial_load = false) { 209 $pids = is_array($pids) ? $pids : array($pids); 210 $fetch_pids = array(); 211 foreach ($pids as $p) 212 if (!isset($this->prows[$p]) && !isset($this->pid_attempts[$p])) 213 $fetch_pids[] = $p; 214 assert($initial_load || empty($fetch_pids)); 215 if (!empty($fetch_pids)) { 216 foreach ($this->user->paper_set($fetch_pids) as $prow) 217 $this->prows[$prow->paperId] = $prow; 218 foreach ($fetch_pids as $pid) 219 if (!isset($this->prows[$pid])) 220 $this->pid_attempts[$pid] = true; 221 } 222 } 223 224 function user_by_id($cid) { 225 return $this->cmap->user_by_id($cid); 226 } 227 function users_by_id($cids) { 228 return array_map(function ($cid) { return $this->user_by_id($cid); }, $cids); 229 } 230 function user_by_email($email, $create = false, $req = null) { 231 return $this->cmap->user_by_email($email, $create, $req); 232 } 233 function none_user() { 234 return $this->cmap->none_user(); 235 } 236 function pc_users() { 237 return $this->cmap->pc_users(); 238 } 239 function reviewer_users() { 240 if ($this->reviewer_users === null) 241 $this->reviewer_users = $this->cmap->reviewer_users($this->paper_ids()); 242 return $this->reviewer_users; 243 } 244 function register_user(Contact $c) { 245 return $this->cmap->register_user($c); 246 } 247 248 function error($message) { 249 $this->errors[] = [$message, true, false]; 250 } 251 function paper_error($message) { 252 $this->errors[] = [$message, $this->paper_exact_match, false]; 253 } 254 function user_error($message) { 255 $this->errors[] = [$message, true, true]; 256 } 257} 258 259class AssignerContacts { 260 private $conf; 261 private $viewer; 262 private $by_id = array(); 263 private $by_lemail = array(); 264 private $has_pc = false; 265 private $none_user; 266 static private $next_fake_id = -10; 267 static public $query = "ContactInfo.contactId, firstName, lastName, unaccentedName, email, roles, contactTags"; 268 function __construct(Conf $conf, Contact $viewer) { 269 global $Me; 270 $this->conf = $conf; 271 $this->viewer = $viewer; 272 if ($Me && $Me->contactId > 0 && $Me->conf === $conf) 273 $this->store($Me); 274 } 275 private function store(Contact $c) { 276 if ($c->contactId != 0) { 277 if (isset($this->by_id[$c->contactId])) 278 return $this->by_id[$c->contactId]; 279 $this->by_id[$c->contactId] = $c; 280 } 281 if ($c->email) 282 $this->by_lemail[strtolower($c->email)] = $c; 283 return $c; 284 } 285 private function ensure_pc() { 286 if (!$this->has_pc) { 287 foreach ($this->conf->pc_members() as $p) 288 $this->store($p); 289 $this->has_pc = true; 290 } 291 } 292 function none_user() { 293 if (!$this->none_user) 294 $this->none_user = new Contact(["contactId" => 0, "roles" => 0, "email" => "", "sorter" => ""], $this->conf); 295 return $this->none_user; 296 } 297 function user_by_id($cid) { 298 if (!$cid) 299 return $this->none_user(); 300 if (($c = get($this->by_id, $cid))) 301 return $c; 302 $this->ensure_pc(); 303 if (($c = get($this->by_id, $cid))) 304 return $c; 305 $result = $this->conf->qe("select " . self::$query . " from ContactInfo where contactId=?", $cid); 306 $c = Contact::fetch($result, $this->conf); 307 if (!$c) 308 $c = new Contact(["contactId" => $cid, "roles" => 0, "email" => "unknown contact $cid", "sorter" => ""], $this->conf); 309 Dbl::free($result); 310 return $this->store($c); 311 } 312 function user_by_email($email, $create = false, $req = null) { 313 if (!$email) 314 return $this->none_user(); 315 $lemail = strtolower($email); 316 if (($c = get($this->by_lemail, $lemail))) 317 return $c; 318 $this->ensure_pc(); 319 if (($c = get($this->by_lemail, $lemail))) 320 return $c; 321 $result = $this->conf->qe("select " . self::$query . " from ContactInfo where email=?", $lemail); 322 $c = Contact::fetch($result, $this->conf); 323 Dbl::free($result); 324 if (!$c && $create) { 325 assert(validate_email($email) || preg_match('/\Aanonymous\d*\z/', $email)); 326 $cargs = ["contactId" => self::$next_fake_id, "roles" => 0, "email" => $email]; 327 foreach (["firstName", "lastName", "affiliation"] as $k) 328 if ($req && get($req, $k)) 329 $cargs[$k] = $req[$k]; 330 if (preg_match('/\Aanonymous\d*\z/', $email)) { 331 $cargs["firstName"] = "Jane Q."; 332 $cargs["lastName"] = "Public"; 333 $cargs["affiliation"] = "Unaffiliated"; 334 $cargs["disabled"] = 1; 335 } 336 $c = new Contact($cargs, $this->conf); 337 self::$next_fake_id -= 1; 338 } 339 return $c ? $this->store($c) : null; 340 } 341 function pc_users() { 342 $this->ensure_pc(); 343 return $this->conf->pc_members(); 344 } 345 function reviewer_users($pids) { 346 $rset = $this->pc_users(); 347 $result = $this->conf->qe("select " . AssignerContacts::$query . " from ContactInfo join PaperReview using (contactId) where (roles&" . Contact::ROLE_PC . ")=0 and paperId?a group by ContactInfo.contactId", $pids); 348 while ($result && ($c = Contact::fetch($result, $this->conf))) 349 $rset[$c->contactId] = $this->store($c); 350 Dbl::free($result); 351 return $rset; 352 } 353 function register_user(Contact $c) { 354 if ($c->contactId >= 0) 355 return $c; 356 assert($this->by_id[$c->contactId] === $c); 357 $cx = $this->by_lemail[strtolower($c->email)]; 358 if ($cx === $c) { 359 // XXX assume that never fails: 360 $cargs = []; 361 foreach (["email", "firstName", "lastName", "affiliation", "disabled"] as $k) 362 if ($c->$k !== null) 363 $cargs[$k] = $c->$k; 364 $cx = Contact::create($this->conf, $this->viewer, $cargs, $cx->is_anonymous_user() ? Contact::SAVE_ANY_EMAIL : 0); 365 $cx = $this->store($cx); 366 } 367 return $cx; 368 } 369} 370 371class AssignmentCount { 372 public $ass = 0; 373 public $rev = 0; 374 public $meta = 0; 375 public $pri = 0; 376 public $sec = 0; 377 public $lead = 0; 378 public $shepherd = 0; 379 function add(AssignmentCount $ct) { 380 $xct = new AssignmentCount; 381 foreach (["rev", "meta", "pri", "sec", "ass", "lead", "shepherd"] as $k) 382 $xct->$k = $this->$k + $ct->$k; 383 return $xct; 384 } 385} 386 387class AssignmentCountSet { 388 public $conf; 389 public $bypc = []; 390 public $rev = false; 391 public $lead = false; 392 public $shepherd = false; 393 function __construct(Conf $conf) { 394 $this->conf = $conf; 395 } 396 function get($offset) { 397 return get($this->bypc, $offset) ? : new AssignmentCount; 398 } 399 function ensure($offset) { 400 if (!isset($this->bypc[$offset])) 401 $this->bypc[$offset] = new AssignmentCount; 402 return $this->bypc[$offset]; 403 } 404 function load_rev() { 405 $result = $this->conf->qe("select u.contactId, group_concat(r.reviewType separator '') 406 from ContactInfo u 407 left join PaperReview r on (r.contactId=u.contactId) 408 left join Paper p on (p.paperId=r.paperId) 409 where p.timeWithdrawn<=0 and p.timeSubmitted>0 410 and u.roles!=0 and (u.roles&" . Contact::ROLE_PC . ")!=0 411 group by u.contactId"); 412 while (($row = edb_row($result))) { 413 $ct = $this->ensure($row[0]); 414 $ct->rev = strlen($row[1]); 415 $ct->meta = substr_count($row[1], REVIEW_META); 416 $ct->pri = substr_count($row[1], REVIEW_PRIMARY); 417 $ct->sec = substr_count($row[1], REVIEW_SECONDARY); 418 } 419 Dbl::free($result); 420 } 421 private function load_paperpc($type) { 422 $result = $this->conf->qe("select {$type}ContactId, count(paperId) 423 from Paper where timeWithdrawn<=0 and timeSubmitted>0 424 group by {$type}ContactId"); 425 while (($row = edb_row($result))) { 426 $ct = $this->ensure($row[0]); 427 $ct->$type = +$row[1]; 428 } 429 Dbl::free($result); 430 } 431 function load_lead() { 432 $this->load_paperpc("lead"); 433 } 434 function load_shepherd() { 435 $this->load_paperpc("shepherd"); 436 } 437} 438 439class AssignmentCsv { 440 public $header = []; 441 public $data = []; 442 function add($row) { 443 foreach ($row as $k => $v) 444 if ($v !== null) 445 $this->header[$k] = true; 446 $this->data[] = $row; 447 } 448 function unparse() { 449 $csvg = new CsvGenerator; 450 return $csvg->select($this->header)->add($this->data)->unparse(); 451 } 452} 453 454class AssignmentParser { 455 public $type; 456 function __construct($type) { 457 $this->type = $type; 458 } 459 function expand_papers(&$req, AssignmentState $state) { 460 return false; 461 } 462 function load_state(AssignmentState $state) { 463 } 464 function allow_paper(PaperInfo $prow, AssignmentState $state) { 465 if (!$state->user->can_administer($prow) 466 && !$state->user->privChair) 467 return "You can’t administer #{$prow->paperId}."; 468 else if ($prow->timeWithdrawn > 0) 469 return "#$prow->paperId has been withdrawn."; 470 else if ($prow->timeSubmitted <= 0) 471 return "#$prow->paperId is not submitted."; 472 else 473 return true; 474 } 475 function contact_set(&$req, AssignmentState $state) { 476 return "pc"; 477 } 478 static function unconflicted(PaperInfo $prow, Contact $contact, AssignmentState $state) { 479 return ($state->overrides & Contact::OVERRIDE_CONFLICT) 480 || !$prow->has_conflict($contact); 481 } 482 function paper_filter($contact, &$req, AssignmentState $state) { 483 return false; 484 } 485 function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) { 486 return false; 487 } 488 function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) { 489 return false; 490 } 491 function expand_anonymous_user(PaperInfo $prow, &$req, $user, AssignmentState $state) { 492 return false; 493 } 494 function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 495 return false; 496 } 497 function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 498 return true; 499 } 500} 501 502class UserlessAssignmentParser extends AssignmentParser { 503 function __construct($type) { 504 parent::__construct($type); 505 } 506 function contact_set(&$req, AssignmentState $state) { 507 return false; 508 } 509 function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) { 510 return [$state->none_user()]; 511 } 512 function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) { 513 return [$state->none_user()]; 514 } 515 function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 516 return true; 517 } 518} 519 520class Assigner { 521 public $item; 522 public $type; 523 public $pid; 524 public $contact; 525 public $cid; 526 public $next_index; 527 function __construct(AssignmentItem $item, AssignmentState $state) { 528 $this->item = $item; 529 $this->type = $item["type"]; 530 $this->pid = $item["pid"]; 531 $this->cid = $item["cid"] ? : $item["_cid"]; 532 if ($this->cid) 533 $this->contact = $state->user_by_id($this->cid); 534 } 535 function unparse_description() { 536 return ""; 537 } 538 function unparse_display(AssignmentSet $aset) { 539 return ""; 540 } 541 function unparse_csv(AssignmentSet $aset, AssignmentCsv $acsv) { 542 return null; 543 } 544 function account(AssignmentSet $aset, AssignmentCountSet $delta) { 545 } 546 function add_locks(AssignmentSet $aset, &$locks) { 547 } 548 function execute(AssignmentSet $aset) { 549 } 550 function cleanup(AssignmentSet $aset) { 551 } 552} 553 554class Null_AssignmentParser extends UserlessAssignmentParser { 555 function __construct() { 556 parent::__construct("none"); 557 } 558 function allow_paper(PaperInfo $prow, AssignmentState $state) { 559 return true; 560 } 561 function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 562 return true; 563 } 564} 565 566class ReviewAssigner_Data { 567 public $oldround = null; 568 public $newround = null; 569 public $explicitround = false; 570 public $oldtype = null; 571 public $newtype = null; 572 public $creator = true; 573 public $error = false; 574 static function separate($key, $req, $state, $rtype) { 575 $a0 = $a1 = trim(get_s($req, $key)); 576 $require_match = $rtype ? false : $a0 !== ""; 577 if ($a0 === "" && $rtype != 0) 578 $a0 = $a1 = get($state->defaults, $key); 579 if ($a0 !== null && ($colon = strpos($a0, ":")) !== false) { 580 $a1 = (string) substr($a0, $colon + 1); 581 $a0 = (string) substr($a0, 0, $colon); 582 $require_match = true; 583 } 584 $a0 = is_string($a0) ? trim($a0) : $a0; 585 $a1 = is_string($a1) ? trim($a1) : $a1; 586 if (strcasecmp($a0, "any") == 0) { 587 $a0 = null; 588 $require_match = true; 589 } 590 if (strcasecmp($a1, "any") == 0) { 591 $a1 = null; 592 $require_match = true; 593 } 594 return [$a0, $a1, $require_match]; 595 } 596 function __construct($req, AssignmentState $state, $rtype) { 597 list($targ0, $targ1, $tmatch) = self::separate("reviewtype", $req, $state, $rtype); 598 if ($targ0 !== null && $targ0 !== "" && $tmatch 599 && ($this->oldtype = ReviewInfo::parse_type($targ0)) === false) 600 $this->error = "Invalid reviewtype."; 601 if ($targ1 !== null && $targ1 !== "" && $rtype != 0 602 && ($this->newtype = ReviewInfo::parse_type($targ1)) === false) 603 $this->error = "Invalid reviewtype."; 604 if ($this->newtype === null) 605 $this->newtype = $rtype; 606 607 list($rarg0, $rarg1, $rmatch) = self::separate("round", $req, $state, $this->newtype); 608 if ($rarg0 !== null && $rarg0 !== "" && $rmatch 609 && ($this->oldround = $state->conf->sanitize_round_name($rarg0)) === false) 610 $this->error = Conf::round_name_error($rarg0); 611 if ($rarg1 !== null && $rarg1 !== "" && $this->newtype != 0 612 && ($this->newround = $state->conf->sanitize_round_name($rarg1)) === false) 613 $this->error = Conf::round_name_error($rarg1); 614 if ($rarg0 !== "" && $rarg1 !== null) 615 $this->explicitround = (string) get($req, "round") !== ""; 616 if ($rarg0 === "") 617 $rmatch = false; 618 if ($this->oldtype === null && $rtype > 0 && $rmatch) 619 $this->oldtype = $rtype; 620 621 $this->creator = !$tmatch && !$rmatch && $this->newtype != 0; 622 } 623 static function make(&$req, AssignmentState $state, $rtype) { 624 if (!isset($req["_review_data"]) || !is_object($req["_review_data"])) 625 $req["_review_data"] = new ReviewAssigner_Data($req, $state, $rtype); 626 return $req["_review_data"]; 627 } 628 function can_create_review() { 629 return $this->creator; 630 } 631} 632 633class Review_AssignmentParser extends AssignmentParser { 634 private $rtype; 635 function __construct(Conf $conf, $aj) { 636 parent::__construct($aj->name); 637 if ($aj->review_type) 638 $this->rtype = (int) ReviewInfo::parse_type($aj->review_type); 639 else 640 $this->rtype = -1; 641 } 642 function load_state(AssignmentState $state) { 643 if ($state->mark_type("review", ["pid", "cid"], "Review_Assigner::make")) 644 self::load_review_state($state); 645 } 646 private function make_rdata(&$req, AssignmentState $state) { 647 return ReviewAssigner_Data::make($req, $state, $this->rtype); 648 } 649 function contact_set(&$req, AssignmentState $state) { 650 if ($this->rtype > REVIEW_EXTERNAL) 651 return "pc"; 652 else if ($this->rtype == 0 653 || (($rdata = $this->make_rdata($req, $state)) 654 && !$rdata->can_create_review())) 655 return "reviewers"; 656 else 657 return false; 658 } 659 static function load_review_state(AssignmentState $state) { 660 $result = $state->conf->qe("select paperId, contactId, reviewType, reviewRound, reviewSubmitted from PaperReview where paperId?a", $state->paper_ids()); 661 while (($row = edb_row($result))) { 662 $round = $state->conf->round_name($row[3]); 663 $state->load(["type" => "review", "pid" => +$row[0], "cid" => +$row[1], 664 "_rtype" => +$row[2], "_round" => $round, 665 "_rsubmitted" => $row[4] > 0 ? 1 : 0]); 666 } 667 Dbl::free($result); 668 } 669 private function make_filter($fkey, $key, $value, &$req, AssignmentState $state) { 670 $rdata = $this->make_rdata($req, $state); 671 if ($rdata->can_create_review()) 672 return null; 673 return $state->make_filter($fkey, [ 674 "type" => "review", $key => $value, 675 "_rtype" => $rdata->oldtype, "_round" => $rdata->oldround 676 ]); 677 } 678 function paper_filter($contact, &$req, AssignmentState $state) { 679 return $this->make_filter("pid", "cid", $contact->contactId, $req, $state); 680 } 681 function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) { 682 $cf = $this->make_filter("cid", "pid", $prow->paperId, $req, $state); 683 return $cf !== null ? $state->users_by_id(array_keys($cf)) : false; 684 } 685 function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) { 686 return $this->expand_any_user($prow, $req, $state); 687 } 688 function expand_anonymous_user(PaperInfo $prow, &$req, $user, AssignmentState $state) { 689 if (preg_match('/\A(?:new-?anonymous|anonymous-?new)\z/', $user)) { 690 $suf = ""; 691 while (($u = $state->user_by_email("anonymous" . $suf)) 692 && $state->query(["type" => "review", "pid" => $prow->paperId, 693 "cid" => $u->contactId])) 694 $suf = $suf === "" ? 2 : $suf + 1; 695 $user = "anonymous" . $suf; 696 } 697 if (preg_match('/\Aanonymous\d*\z/', $user) 698 && $c = $state->user_by_email($user, true, [])) 699 return [$c]; 700 else 701 return false; 702 } 703 function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 704 // User “none” is never allowed 705 if (!$contact->contactId) 706 return false; 707 // PC reviews must be PC members 708 $rdata = $this->make_rdata($req, $state); 709 if ($rdata->newtype >= REVIEW_PC && !$contact->is_pc_member()) 710 return Text::user_html_nolink($contact) . " is not a PC member and cannot be assigned a PC review."; 711 // Conflict allowed if we're not going to assign a new review 712 if ($this->rtype == 0 713 || $prow->has_reviewer($contact) 714 || !$rdata->can_create_review()) 715 return true; 716 // Check whether review assignments are acceptable 717 if ($contact->is_pc_member() 718 && !$contact->can_accept_review_assignment_ignore_conflict($prow)) 719 return Text::user_html_nolink($contact) . " cannot be assigned to review #{$prow->paperId}."; 720 // Check conflicts 721 return AssignmentParser::unconflicted($prow, $contact, $state); 722 } 723 function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 724 $rdata = $this->make_rdata($req, $state); 725 if ($rdata->error) 726 return $rdata->error; 727 728 $revmatch = ["type" => "review", "pid" => $prow->paperId, 729 "cid" => $contact->contactId, 730 "_rtype" => $rdata->oldtype, "_round" => $rdata->oldround]; 731 $res = $state->remove($revmatch); 732 assert(count($res) <= 1); 733 734 if ($rdata->can_create_review() && empty($res)) { 735 $revmatch["_round"] = $rdata->newround; 736 $res[] = $revmatch; 737 } 738 if ($rdata->newtype && !empty($res)) { 739 $m = $res[0]; 740 if (!$m["_rtype"] || $rdata->newtype > 0) 741 $m["_rtype"] = $rdata->newtype; 742 if (!$m["_rtype"] || $m["_rtype"] < 0) 743 $m["_rtype"] = REVIEW_EXTERNAL; 744 if ($m["_rtype"] == REVIEW_EXTERNAL 745 && $state->conf->pc_member_by_id($m["cid"])) 746 $m["_rtype"] = REVIEW_PC; 747 if ($rdata->newround !== null && $rdata->explicitround) 748 $m["_round"] = $rdata->newround; 749 $state->add($m); 750 } else if (!$rdata->newtype && !empty($res) && $res[0]["_rsubmitted"]) 751 // do not remove submitted reviews 752 $state->add($res[0]); 753 return true; 754 } 755} 756 757class Review_Assigner extends Assigner { 758 private $rtype; 759 private $notify = false; 760 private $unsubmit = false; 761 private $token = false; 762 static public $prefinfo = null; 763 function __construct(AssignmentItem $item, AssignmentState $state) { 764 parent::__construct($item, $state); 765 $this->rtype = $item->get(false, "_rtype"); 766 $this->unsubmit = $item->get(true, "_rsubmitted") && !$item->get(false, "_rsubmitted"); 767 if (!$item->existed() && $this->rtype == REVIEW_EXTERNAL 768 && !$this->contact->is_anonymous_user() 769 && ($notify = get($state->defaults, "extrev_notify")) 770 && Mailer::is_template($notify)) 771 $this->notify = $notify; 772 } 773 static function make(AssignmentItem $item, AssignmentState $state) { 774 return new Review_Assigner($item, $state); 775 } 776 function unparse_description() { 777 return "review"; 778 } 779 private function unparse_item(AssignmentSet $aset, $before) { 780 if (!$this->item->get($before, "_rtype")) 781 return ""; 782 $t = $aset->user->reviewer_html_for($this->contact) . ' ' 783 . review_type_icon($this->item->get($before, "_rtype"), 784 !$this->item->get($before, "_rsubmitted")); 785 if (($round = $this->item->get($before, "_round"))) 786 $t .= ' <span class="revround" title="Review round">' 787 . htmlspecialchars($round) . '</span>'; 788 if (self::$prefinfo 789 && ($cpref = get(self::$prefinfo, $this->cid)) 790 && ($pref = get($cpref, $this->pid))) 791 $t .= unparse_preference_span($pref); 792 return $t; 793 } 794 private function icon($before) { 795 return review_type_icon($this->item->get($before, "_rtype"), 796 !$this->item->get($before, "_rsubmitted")); 797 } 798 function unparse_display(AssignmentSet $aset) { 799 $t = $aset->user->reviewer_html_for($this->contact); 800 if ($this->item->deleted()) 801 $t = '<del>' . $t . '</del>'; 802 if ($this->item->differs("_rtype") || $this->item->differs("_rsubmitted")) { 803 if ($this->item->get(true, "_rtype")) 804 $t .= ' <del>' . $this->icon(true) . '</del>'; 805 if ($this->item->get(false, "_rtype")) 806 $t .= ' <ins>' . $this->icon(false) . '</ins>'; 807 } else if ($this->item["_rtype"]) 808 $t .= ' ' . $this->icon(false); 809 if ($this->item->differs("_round")) { 810 if (($round = $this->item->get(true, "_round"))) 811 $t .= ' <del><span class="revround" title="Review round">' . htmlspecialchars($round) . '</span></del>'; 812 if (($round = $this->item->get(false, "_round"))) 813 $t .= ' <ins><span class="revround" title="Review round">' . htmlspecialchars($round) . '</span></ins>'; 814 } else if (($round = $this->item["_round"])) 815 $t .= ' <span class="revround" title="Review round">' . htmlspecialchars($round) . '</span>'; 816 if (!$this->item->existed() && self::$prefinfo 817 && ($cpref = get(self::$prefinfo, $this->cid)) 818 && ($pref = get($cpref, $this->pid))) 819 $t .= unparse_preference_span($pref); 820 return $t; 821 } 822 function unparse_csv(AssignmentSet $aset, AssignmentCsv $acsv) { 823 $x = ["pid" => $this->pid, "action" => ReviewInfo::unparse_assigner_action($this->rtype), 824 "email" => $this->contact->email, "name" => $this->contact->name_text()]; 825 if (($round = $this->item["_round"])) 826 $x["round"] = $this->item["_round"]; 827 if ($this->token) 828 $x["review_token"] = encode_token($this->token); 829 $acsv->add($x); 830 if ($this->unsubmit) 831 $acsv->add(["action" => "unsubmitreview", "pid" => $this->pid, 832 "email" => $this->contact->email, "name" => $this->contact->name_text()]); 833 } 834 function account(AssignmentSet $aset, AssignmentCountSet $deltarev) { 835 $aset->show_column("reviewers"); 836 if ($this->cid > 0) { 837 $deltarev->rev = true; 838 $ct = $deltarev->ensure($this->cid); 839 ++$ct->ass; 840 $oldtype = $this->item->get(true, "_rtype") ? : 0; 841 $ct->rev += ($this->rtype != 0) - ($oldtype != 0); 842 $ct->meta += ($this->rtype == REVIEW_META) - ($oldtype == REVIEW_META); 843 $ct->pri += ($this->rtype == REVIEW_PRIMARY) - ($oldtype == REVIEW_PRIMARY); 844 $ct->sec += ($this->rtype == REVIEW_SECONDARY) - ($oldtype == REVIEW_SECONDARY); 845 } 846 } 847 function add_locks(AssignmentSet $aset, &$locks) { 848 $locks["PaperReview"] = $locks["PaperReviewRefused"] = $locks["Settings"] = "write"; 849 } 850 function execute(AssignmentSet $aset) { 851 $extra = array(); 852 $round = $this->item->get(false, "_round"); 853 if ($round !== null && $this->rtype) 854 $extra["round_number"] = (int) $aset->conf->round_number($round, true); 855 if ($this->contact->is_anonymous_user() 856 && (!$this->item->existed() || $this->item->deleted())) { 857 $extra["token"] = true; 858 $aset->cleanup_callback("rev_token", function ($aset, $vals) { 859 $aset->conf->update_rev_tokens_setting(min($vals)); 860 }, $this->item->existed() ? 0 : 1); 861 } 862 $reviewId = $aset->user->assign_review($this->pid, $this->cid, $this->rtype, $extra); 863 if ($this->unsubmit && $reviewId) 864 $aset->user->unsubmit_review_row((object) ["paperId" => $this->pid, "contactId" => $this->cid, "reviewType" => $this->rtype, "reviewId" => $reviewId]); 865 if (get($extra, "token") && $reviewId) 866 $this->token = $aset->conf->fetch_ivalue("select reviewToken from PaperReview where paperId=? and reviewId=?", $this->pid, $reviewId); 867 } 868 function cleanup(AssignmentSet $aset) { 869 if ($this->notify) { 870 $reviewer = $aset->conf->user_by_id($this->cid); 871 $prow = $aset->conf->paperRow(["paperId" => $this->pid], $reviewer); 872 HotCRPMailer::send_to($reviewer, $this->notify, $prow); 873 } 874 } 875} 876 877 878class UnsubmitReview_AssignmentParser extends AssignmentParser { 879 function __construct() { 880 parent::__construct("unsubmitreview"); 881 } 882 function load_state(AssignmentState $state) { 883 if ($state->mark_type("review", ["pid", "cid"], "Review_Assigner::make")) 884 Review_AssignmentParser::load_review_state($state); 885 } 886 function contact_set(&$req, AssignmentState $state) { 887 return "reviewers"; 888 } 889 function paper_filter($contact, &$req, AssignmentState $state) { 890 return $state->make_filter("pid", ["type" => "review", "cid" => $contact->contactId, "_rsubmitted" => 1]); 891 } 892 function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) { 893 $cf = $state->make_filter("cid", ["type" => "review", "pid" => $prow->paperId, "_rsubmitted" => 1]); 894 return $state->users_by_id(array_keys($cf)); 895 } 896 function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) { 897 return $this->expand_any_user($prow, $req, $state); 898 } 899 function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 900 return $contact->contactId != 0; 901 } 902 function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) { 903 // parse round and reviewtype arguments 904 $rarg0 = trim(get_s($req, "round")); 905 $oldround = null; 906 if ($rarg0 !== "" && strcasecmp($rarg0, "any") != 0 907 && ($oldround = $state->conf->sanitize_round_name($rarg0)) === false) 908 return Conf::round_name_error($rarg0); 909 $targ0 = trim(get_s($req, "reviewtype")); 910 $oldtype = null; 911 if ($targ0 !== "" 912 && ($oldtype = ReviewInfo::parse_type($targ0)) === false) 913 return "Invalid reviewtype."; 914 915 // remove existing review 916 $revmatch = ["type" => "review", "pid" => $prow->paperId, 917 "cid" => $contact->contactId, 918 "_rtype" => $oldtype, "_round" => $oldround, "_rsubmitted" => 1]; 919 $matches = $state->remove($revmatch); 920 foreach ($matches as $r) { 921 $r["_rsubmitted"] = 0; 922 $state->add($r); 923 } 924 return true; 925 } 926} 927 928 929class AssignmentSet { 930 public $conf; 931 public $user; 932 public $filename; 933 private $assigners = []; 934 private $assigners_pidhead = []; 935 private $enabled_pids = null; 936 private $enabled_actions = null; 937 private $msgs = array(); 938 private $has_error = false; 939 private $has_user_error = false; 940 private $my_conflicts = null; 941 private $astate; 942 private $searches = array(); 943 private $search_type = "s"; 944 private $unparse_search = false; 945 private $unparse_columns = array(); 946 private $assignment_type; 947 private $cleanup_callbacks; 948 private $cleanup_notify_tracker; 949 private $qe_stager; 950 951 function __construct(Contact $user, $overrides = null) { 952 $this->conf = $user->conf; 953 $this->user = $user; 954 $this->astate = new AssignmentState($user); 955 $this->set_overrides($overrides); 956 } 957 958 function set_search_type($search_type) { 959 $this->search_type = $search_type; 960 } 961 function set_reviewer(Contact $reviewer) { 962 $this->astate->reviewer = $reviewer; 963 } 964 function set_overrides($overrides) { 965 if ($overrides === null) 966 $overrides = $this->user->overrides(); 967 else if ($overrides === true) 968 $overrides = $this->user->overrides() | Contact::OVERRIDE_CONFLICT; 969 if (!$this->user->privChair) 970 $overrides &= ~Contact::OVERRIDE_CONFLICT; 971 $this->astate->overrides = (int) $overrides; 972 } 973 974 function enable_actions($action) { 975 assert(empty($this->assigners)); 976 if ($this->enabled_actions === null) 977 $this->enabled_actions = []; 978 foreach (is_array($action) ? $action : [$action] as $a) 979 if (($aparser = $this->conf->assignment_parser($a, $this->user))) 980 $this->enabled_actions[$aparser->type] = true; 981 } 982 983 function enable_papers($paper) { 984 assert(empty($this->assigners)); 985 if ($this->enabled_pids === null) 986 $this->enabled_pids = []; 987 foreach (is_array($paper) ? $paper : [$paper] as $p) 988 if ($p instanceof PaperInfo) { 989 $this->astate->add_prow($p); 990 $this->enabled_pids[] = $p->paperId; 991 } else 992 $this->enabled_pids[] = (int) $p; 993 } 994 995 function is_empty() { 996 return empty($this->assigners); 997 } 998 999 function has_error() { 1000 return $this->has_error; 1001 } 1002 1003 function clear_errors() { 1004 $this->msgs = []; 1005 $this->has_error = false; 1006 $this->has_user_error = false; 1007 } 1008 1009 function msg($lineno, $msg, $status) { 1010 $l = ($this->filename ? $this->filename . ":" : "line ") . $lineno; 1011 $n = count($this->msgs) - 1; 1012 if ($n >= 0 1013 && $this->msgs[$n][0] === $l 1014 && $this->msgs[$n][1] === $msg) 1015 $this->msgs[$n][2] = max($this->msgs[$n][2], $status); 1016 else 1017 $this->msgs[] = [$l, $msg, $status]; 1018 if ($status == 2) 1019 $this->has_error = true; 1020 } 1021 function error_at($lineno, $message) { 1022 $this->msg($lineno, $message, 2); 1023 } 1024 function error_here($message) { 1025 $this->msg($this->astate->lineno, $message, 2); 1026 } 1027 1028 function errors_html($linenos = false) { 1029 $es = array(); 1030 foreach ($this->msgs as $e) { 1031 $t = $e[1]; 1032 if ($linenos && $e[0]) 1033 $t = '<span class="lineno">' . htmlspecialchars($e[0]) . ':</span> ' . $t; 1034 if (empty($es) || $es[count($es) - 1] !== $t) 1035 $es[] = $t; 1036 } 1037 return $es; 1038 } 1039 function errors_div_html($linenos = false) { 1040 $es = $this->errors_html($linenos); 1041 if (empty($es)) 1042 return ""; 1043 else if ($linenos) 1044 return '<div class="parseerr"><p>' . join("</p>\n<p>", $es) . '</p></div>'; 1045 else if (count($es) == 1) 1046 return $es[0]; 1047 else 1048 return '<div><div class="mmm">' . join('</div><div class="mmm">', $es) . '</div></div>'; 1049 } 1050 function errors_text($linenos = false) { 1051 $es = array(); 1052 foreach ($this->msgs as $e) { 1053 $t = htmlspecialchars_decode(preg_replace(',<(?:[^\'">]|\'[^\']*\'|"[^"]*")*>,', "", $e[1])); 1054 if ($linenos && $e[0]) 1055 $t = $e[0] . ': ' . $t; 1056 if (empty($es) || $es[count($es) - 1] !== $t) 1057 $es[] = $t; 1058 } 1059 return $es; 1060 } 1061 1062 function report_errors() { 1063 if (!empty($this->msgs) && $this->has_error) 1064 Conf::msg_error('Assignment errors: ' . $this->errors_div_html(true) . ' Please correct these errors and try again.'); 1065 else if (!empty($this->msgs)) 1066 Conf::msg_warning('Assignment warnings: ' . $this->errors_div_html(true)); 1067 } 1068 1069 function json_result($linenos = false) { 1070 if ($this->has_error) { 1071 $jr = new JsonResult(403, ["ok" => false, "error" => $this->errors_div_html($linenos)]); 1072 if ($this->has_user_error) { 1073 $jr->status = 422; 1074 $jr->content["user_error"] = true; 1075 } 1076 return $jr; 1077 } else if (!empty($this->msgs)) { 1078 return new JsonResult(["ok" => true, "response" => $this->errors_div_html($linenos)]); 1079 } else { 1080 return new JsonResult(["ok" => true]); 1081 } 1082 } 1083 1084 private static function req_user_html($req) { 1085 return Text::user_html_nolink(get($req, "firstName"), get($req, "lastName"), get($req, "email")); 1086 } 1087 1088 private function set_my_conflicts() { 1089 $this->my_conflicts = array(); 1090 $result = $this->conf->qe("select Paper.paperId, managerContactId from Paper join PaperConflict on (PaperConflict.paperId=Paper.paperId) where conflictType>0 and PaperConflict.contactId=?", $this->user->contactId); 1091 while (($row = edb_row($result))) 1092 $this->my_conflicts[$row[0]] = ($row[1] ? $row[1] : true); 1093 Dbl::free($result); 1094 } 1095 1096 private static function apply_user_parts(&$req, $a) { 1097 foreach (array("firstName", "lastName", "email") as $i => $k) 1098 if (!get($req, $k) && get($a, $i)) 1099 $req[$k] = $a[$i]; 1100 } 1101 1102 private function lookup_users(&$req, $assigner) { 1103 // move all usable identification data to email, firstName, lastName 1104 if (isset($req["name"])) 1105 self::apply_user_parts($req, Text::split_name($req["name"])); 1106 if (isset($req["user"]) && strpos($req["user"], " ") === false) { 1107 if (!get($req, "email")) 1108 $req["email"] = $req["user"]; 1109 } else if (isset($req["user"])) 1110 self::apply_user_parts($req, Text::split_name($req["user"], true)); 1111 1112 // extract email, first, last 1113 $first = get($req, "firstName"); 1114 $last = get($req, "lastName"); 1115 $email = trim((string) get($req, "email")); 1116 $lemail = strtolower($email); 1117 $special = null; 1118 if ($lemail) 1119 $special = $lemail; 1120 else if (!$first && $last && strpos(trim($last), " ") === false) 1121 $special = trim(strtolower($last)); 1122 $xspecial = $special; 1123 1124 // check special: missing, "none", "any", "pc", "me", PC tag, "external" 1125 if ($special === "all" || $special === "any") 1126 return "any"; 1127 else if ($special === "missing" || (!$first && !$last && !$lemail)) 1128 return "missing"; 1129 else if ($special === "none") 1130 return [$this->astate->none_user()]; 1131 else if (preg_match('/\A(?:new-?)?anonymous(?:\d*|-?new)\z/', $special)) 1132 return $special; 1133 if ($special && !$first && (!$lemail || !$last)) { 1134 $ret = ContactSearch::make_special($special, $this->astate->user); 1135 if ($ret->ids !== false) 1136 return $ret->contacts(); 1137 } 1138 if (($special === "ext" || $special === "external") 1139 && $assigner->contact_set($req, $this->astate) === "reviewers") { 1140 $ret = array(); 1141 foreach ($this->astate->reviewer_users() as $u) 1142 if (!$u->is_pc_member()) 1143 $ret[] = $u; 1144 return $ret; 1145 } 1146 1147 // check for precise email match on existing contact (common case) 1148 if ($lemail && ($contact = $this->astate->user_by_email($email, false))) 1149 return array($contact); 1150 1151 // check PC list 1152 $cset = $assigner->contact_set($req, $this->astate); 1153 $cset_text = "user"; 1154 if ($cset === "pc") { 1155 $cset = $this->astate->pc_users(); 1156 $cset_text = "PC member"; 1157 } else if ($cset === "reviewers") { 1158 $cset = $this->astate->reviewer_users(); 1159 $cset_text = "reviewer"; 1160 } 1161 if ($cset) { 1162 $text = ""; 1163 if ($first && $last) 1164 $text = "$last, $first"; 1165 else if ($first || $last) 1166 $text = "$last$first"; 1167 if ($email) 1168 $text .= " <$email>"; 1169 $ret = ContactSearch::make_cset($text, $this->astate->user, $cset); 1170 if (count($ret->ids) == 1) 1171 return $ret->contacts(); 1172 else if (empty($ret->ids)) 1173 $this->error_here("No $cset_text matches “" . self::req_user_html($req) . "”."); 1174 else 1175 $this->error_here("“" . self::req_user_html($req) . "” matches more than one $cset_text, use a full email address to disambiguate."); 1176 return false; 1177 } 1178 1179 // create contact 1180 if (!$email) 1181 return $this->error_here("Missing email address."); 1182 else if (!validate_email($email)) 1183 return $this->error_here("Email address “" . htmlspecialchars($email) . "” is invalid."); 1184 else if (($u = $this->astate->user_by_email($email, true, $req))) 1185 return [$u]; 1186 else 1187 return $this->error_here("Could not create user."); 1188 } 1189 1190 static private function is_csv_header($req) { 1191 foreach (array("action", "assignment", "paper", "pid", "paperId") as $k) 1192 if (array_search($k, $req) !== false) 1193 return true; 1194 return false; 1195 } 1196 1197 private function install_csv_header($csv, $req) { 1198 if (!self::is_csv_header($req)) { 1199 $csv->unshift($req); 1200 if (count($req) == 3 1201 && (!$req[2] || strpos($req[2], "@") !== false)) 1202 $req = ["paper", "name", "email"]; 1203 else if (count($req) == 2) 1204 $req = ["paper", "user"]; 1205 else 1206 $req = ["paper", "action", "user", "round"]; 1207 } else { 1208 $cleans = array("paper", "pid", "paper", "paperId", 1209 "firstName", "first", "lastName", "last", 1210 "firstName", "firstname", "lastName", "lastname", 1211 "preference", "pref"); 1212 for ($i = 0; $i < count($cleans); $i += 2) 1213 if (array_search($cleans[$i], $req) === false 1214 && ($j = array_search($cleans[$i + 1], $req)) !== false) 1215 $req[$j] = $cleans[$i]; 1216 } 1217 1218 $has_action = array_search("action", $req) !== false 1219 || array_search("assignment", $req) !== false; 1220 if (!$has_action && !isset($this->astate->defaults["action"])) { 1221 $defaults = $modifications = []; 1222 if (array_search("tag", $req) !== false) 1223 $defaults[] = "tag"; 1224 if (array_search("preference", $req) !== false) 1225 $defaults[] = "preference"; 1226 if (($j = array_search("lead", $req)) !== false) { 1227 $defaults[] = "lead"; 1228 $modifications = [$j, "user"]; 1229 } 1230 if (($j = array_search("shepherd", $req)) !== false) { 1231 $defaults[] = "shepherd"; 1232 $modifications = [$j, "user"]; 1233 } 1234 if (($j = array_search("decision", $req)) !== false) { 1235 $defaults[] = "decision"; 1236 $modifications = [$j, "decision"]; 1237 } 1238 if (count($defaults) == 1) { 1239 $this->astate->defaults["action"] = $defaults[0]; 1240 for ($i = 0; $i < count($modifications); $i += 2) 1241 $req[$modifications[$i]] = $modifications[$i + 1]; 1242 } 1243 } 1244 $csv->set_header($req); 1245 1246 if (!$has_action && !get($this->astate->defaults, "action")) 1247 return $this->error_at($csv->lineno(), "“assignment” column missing"); 1248 else if (array_search("paper", $req) === false) 1249 return $this->error_at($csv->lineno(), "“paper” column missing"); 1250 else { 1251 if (!isset($this->astate->defaults["action"])) 1252 $this->astate->defaults["action"] = "<missing>"; 1253 return true; 1254 } 1255 } 1256 1257 function hide_column($coldesc, $force = false) { 1258 if (!isset($this->unparse_columns[$coldesc]) || $force) 1259 $this->unparse_columns[$coldesc] = false; 1260 } 1261 1262 function show_column($coldesc, $force = false) { 1263 if (!isset($this->unparse_columns[$coldesc]) || $force) 1264 $this->unparse_columns[$coldesc] = true; 1265 } 1266 1267 function parse_csv_comment($line) { 1268 if (preg_match('/\A#\s*hotcrp_assign_display_search\s*(\S.*)\s*\z/', $line, $m)) 1269 $this->unparse_search = $m[1]; 1270 if (preg_match('/\A#\s*hotcrp_assign_show\s+(\w+)\s*\z/', $line, $m)) 1271 $this->show_column($m[1]); 1272 } 1273 1274 private function collect_papers($pfield, &$pids, $report_error) { 1275 $pfield = trim($pfield); 1276 if ($pfield !== "" && preg_match('/\A[\d,\s]+\z/', $pfield)) { 1277 $npids = []; 1278 foreach (preg_split('/[,\s]+/', $pfield) as $pid) 1279 $npids[] = intval($pid); 1280 $val = 2; 1281 } else if ($pfield !== "") { 1282 if (!isset($this->searches[$pfield])) { 1283 $search = new PaperSearch($this->user, ["q" => $pfield, "reviewer" => $this->astate->reviewer]); 1284 $this->searches[$pfield] = $search->paper_ids(); 1285 if ($report_error) 1286 foreach ($search->warnings as $w) 1287 $this->error_here($w); 1288 } 1289 $npids = $this->searches[$pfield]; 1290 $val = 1; 1291 } else { 1292 if ($report_error) 1293 $this->error_here("Bad paper column"); 1294 return 0; 1295 } 1296 if (empty($npids) && $report_error) 1297 $this->msg($this->astate->lineno, "No papers match “" . htmlspecialchars($pfield) . "”", 1); 1298 1299 // Implement paper restriction 1300 if ($this->enabled_pids !== null) 1301 $npids = array_intersect($npids, $this->enabled_pids); 1302 1303 foreach ($npids as $pid) 1304 $pids[$pid] = $val; 1305 return $val; 1306 } 1307 1308 private function collect_parser($req) { 1309 if (($action = get($req, "action")) === null 1310 && ($action = get($req, "assignment")) === null 1311 && ($action = get($req, "type")) === null) 1312 $action = $this->astate->defaults["action"]; 1313 $action = strtolower(trim($action)); 1314 return $this->conf->assignment_parser($action, $this->user); 1315 } 1316 1317 private function expand_special_user($user, AssignmentParser $aparser, PaperInfo $prow, $req) { 1318 global $Now; 1319 if ($user === "any") 1320 $u = $aparser->expand_any_user($prow, $req, $this->astate); 1321 else if ($user === "missing") { 1322 $u = $aparser->expand_missing_user($prow, $req, $this->astate); 1323 if ($u === false || $u === null) { 1324 $this->astate->error("User required."); 1325 return false; 1326 } 1327 } else if (preg_match('/\A(?:new-?)?anonymous/', $user)) 1328 $u = $aparser->expand_anonymous_user($prow, $req, $user, $this->astate); 1329 else 1330 $u = false; 1331 if ($u === false || $u === null) 1332 $this->astate->error("User “" . htmlspecialchars($user) . "” is not allowed here."); 1333 return $u; 1334 } 1335 1336 private function apply($aparser, $req) { 1337 // parse paper 1338 $pids = []; 1339 $x = $this->collect_papers((string) get($req, "paper"), $pids, true); 1340 if (empty($pids)) 1341 return false; 1342 $pfield_straight = $x == 2; 1343 $pids = array_keys($pids); 1344 1345 // check action 1346 if (!$aparser) 1347 return $this->error_here("Unknown action."); 1348 if ($this->enabled_actions !== null 1349 && !isset($this->enabled_actions[$aparser->type])) 1350 return $this->error_here("Action " . htmlspecialchars($aparser->type) . " disabled."); 1351 $aparser->load_state($this->astate); 1352 1353 // clean user parts 1354 $contacts = $this->lookup_users($req, $aparser); 1355 if ($contacts === false || $contacts === null) 1356 return false; 1357 1358 // maybe filter papers 1359 if (count($pids) > 20 1360 && is_array($contacts) 1361 && count($contacts) == 1 1362 && $contacts[0]->contactId > 0 1363 && ($pf = $aparser->paper_filter($contacts[0], $req, $this->astate))) { 1364 $npids = []; 1365 foreach ($pids as $p) 1366 if (get($pf, $p)) 1367 $npids[] = $p; 1368 $pids = $npids; 1369 } 1370 1371 // fetch papers 1372 $this->astate->fetch_prows($pids); 1373 $this->astate->errors = []; 1374 $this->astate->paper_exact_match = $pfield_straight; 1375 1376 // check conflicts and perform assignment 1377 $any_success = false; 1378 foreach ($pids as $p) { 1379 assert(is_int($p)); 1380 $prow = $this->astate->prow($p); 1381 if (!$prow) { 1382 $this->error_here("Submission #$p does not exist."); 1383 continue; 1384 } 1385 1386 $err = $aparser->allow_paper($prow, $this->astate); 1387 if ($err !== true) { 1388 if (is_string($err)) 1389 $this->astate->paper_error($err); 1390 continue; 1391 } 1392 1393 $this->encounter_order[$p] = $p; 1394 1395 // expand “all” and “missing” 1396 $pusers = $contacts; 1397 if (!is_array($pusers)) { 1398 $pusers = $this->expand_special_user($pusers, $aparser, $prow, $req); 1399 if ($pusers === false || $pusers === null) 1400 break; 1401 } 1402 1403 foreach ($pusers as $contact) { 1404 $err = $aparser->allow_contact($prow, $contact, $req, $this->astate); 1405 if ($err === false) { 1406 if (!$contact->contactId) { 1407 $this->astate->error("User “none” is not allowed here. [{$contact->email}]"); 1408 break 2; 1409 } else if ($prow->has_conflict($contact)) { 1410 $err = Text::user_html_nolink($contact) . " has a conflict with #$p."; 1411 } else { 1412 $err = Text::user_html_nolink($contact) . " cannot be assigned to #$p."; 1413 } 1414 } 1415 if ($err !== true) { 1416 if (is_string($err)) { 1417 $this->astate->paper_error($err); 1418 } 1419 continue; 1420 } 1421 1422 $err = $aparser->apply($prow, $contact, $req, $this->astate); 1423 if ($err !== true) { 1424 if (is_string($err)) { 1425 $this->astate->error($err); 1426 } 1427 continue; 1428 } 1429 1430 $any_success = true; 1431 } 1432 } 1433 1434 foreach ($this->astate->errors as $e) { 1435 $this->msg($this->astate->lineno, $e[0], $e[1] || !$any_success ? 2 : 1); 1436 if ($e[2]) 1437 $this->has_user_error = true; 1438 } 1439 return $any_success; 1440 } 1441 1442 function parse($text, $filename = null, $defaults = null, $alertf = null) { 1443 assert(empty($this->assigners)); 1444 $this->filename = $filename; 1445 $this->astate->defaults = $defaults ? : array(); 1446 1447 if ($text instanceof CsvParser) 1448 $csv = $text; 1449 else { 1450 $csv = new CsvParser($text, CsvParser::TYPE_GUESS); 1451 $csv->set_comment_chars("%#"); 1452 $csv->set_comment_function(array($this, "parse_csv_comment")); 1453 } 1454 if (!($req = $csv->header() ? : $csv->next())) 1455 return $this->error_at($csv->lineno(), "empty file"); 1456 if (!$this->install_csv_header($csv, $req)) 1457 return false; 1458 1459 $old_overrides = $this->user->set_overrides($this->astate->overrides); 1460 1461 // parse file, load papers all at once 1462 $lines = $pids = []; 1463 while (($req = $csv->next()) !== false) { 1464 $aparser = $this->collect_parser($req); 1465 $this->collect_papers((string) get($req, "paper"), $pids, false); 1466 if ($aparser 1467 && ($pfield = $aparser->expand_papers($req, $this->astate))) 1468 $this->collect_papers($pfield, $pids, false); 1469 $lines[] = [$csv->lineno(), $aparser, $req]; 1470 } 1471 if (!empty($pids)) { 1472 $this->astate->lineno = $csv->lineno(); 1473 $this->astate->fetch_prows(array_keys($pids), true); 1474 } 1475 1476 // now parse assignment 1477 foreach ($lines as $i => $linereq) { 1478 $this->astate->lineno = $linereq[0]; 1479 if ($i % 100 == 0) { 1480 if ($alertf) 1481 call_user_func($alertf, $this, $linereq[0], $linereq[2]); 1482 set_time_limit(30); 1483 } 1484 $this->apply($linereq[1], $linereq[2]); 1485 } 1486 if ($alertf) 1487 call_user_func($alertf, $this, $csv->lineno(), false); 1488 1489 // call finishers 1490 foreach ($this->astate->finishers as $fin) 1491 $fin->apply_finisher($this->astate); 1492 1493 // create assigners for difference 1494 $this->assigners_pidhead = $pidtail = []; 1495 foreach ($this->astate->diff() as $pid => $difflist) 1496 foreach ($difflist as $item) { 1497 try { 1498 if (($a = $item->realize($this->astate))) { 1499 if ($a->pid > 0) { 1500 $index = count($this->assigners); 1501 if (isset($pidtail[$a->pid])) 1502 $pidtail[$a->pid]->next_index = $index; 1503 else 1504 $this->assigners_pidhead[$a->pid] = $index; 1505 $pidtail[$a->pid] = $a; 1506 } 1507 $this->assigners[] = $a; 1508 } 1509 } catch (Exception $e) { 1510 $this->error_at($item->lineno, $e->getMessage()); 1511 } 1512 } 1513 1514 $this->user->set_overrides($old_overrides); 1515 } 1516 1517 function assigned_types() { 1518 $types = array(); 1519 foreach ($this->assigners as $assigner) 1520 $types[$assigner->type] = true; 1521 ksort($types); 1522 return array_keys($types); 1523 } 1524 function assigned_pids($compress = false) { 1525 $pids = array_keys($this->assigners_pidhead); 1526 sort($pids, SORT_NUMERIC); 1527 if ($compress) { 1528 $xpids = array(); 1529 $lpid = $rpid = -1; 1530 foreach ($pids as $pid) { 1531 if ($lpid >= 0 && $pid != $rpid + 1) 1532 $xpids[] = $lpid == $rpid ? $lpid : "$lpid-$rpid"; 1533 if ($lpid < 0 || $pid != $rpid + 1) 1534 $lpid = $pid; 1535 $rpid = $pid; 1536 } 1537 if ($lpid >= 0) 1538 $xpids[] = $lpid == $rpid ? $lpid : "$lpid-$rpid"; 1539 $pids = $xpids; 1540 } 1541 return $pids; 1542 } 1543 1544 function type_description() { 1545 if ($this->assignment_type === null) 1546 foreach ($this->assigners as $assigner) { 1547 $desc = $assigner->unparse_description(); 1548 if ($this->assignment_type === null 1549 || $this->assignment_type === $desc) 1550 $this->assignment_type = $desc; 1551 else 1552 $this->assignment_type = ""; 1553 } 1554 return $this->assignment_type; 1555 } 1556 1557 function unparse_paper_assignment(PaperInfo $prow) { 1558 $assigners = []; 1559 for ($index = get($this->assigners_pidhead, $prow->paperId); 1560 $index !== null; 1561 $index = $assigner->next_index) { 1562 $assigners[] = $assigner = $this->assigners[$index]; 1563 if ($assigner->contact && !isset($assigner->contact->sorter)) 1564 Contact::set_sorter($assigner->contact, $this->conf); 1565 } 1566 usort($assigners, function ($assigner1, $assigner2) { 1567 $c1 = $assigner1->contact; 1568 $c2 = $assigner2->contact; 1569 if ($c1 && $c2) 1570 return strnatcasecmp($c1->sorter, $c2->sorter); 1571 else if ($c1 || $c2) 1572 return $c1 ? -1 : 1; 1573 else 1574 return strcmp($c1->type, $c2->type); 1575 }); 1576 $t = ""; 1577 foreach ($assigners as $assigner) { 1578 if (($text = $assigner->unparse_display($this))) { 1579 $t .= ($t ? ", " : "") . '<span class="nw">' . $text . '</span>'; 1580 } 1581 } 1582 if (isset($this->my_conflicts[$prow->paperId])) { 1583 if ($this->my_conflicts[$prow->paperId] !== true) 1584 $t = '<em>Hidden for conflict</em>'; 1585 else 1586 $t = PaperList::wrapChairConflict($t); 1587 } 1588 return $t; 1589 } 1590 function echo_unparse_display() { 1591 $this->set_my_conflicts(); 1592 $deltarev = new AssignmentCountSet($this->conf); 1593 foreach ($this->assigners as $assigner) 1594 $assigner->account($this, $deltarev); 1595 1596 $query = $this->assigned_pids(true); 1597 if ($this->unparse_search) 1598 $query_order = "(" . $this->unparse_search . ") THEN HEADING:none " . join(" ", $query); 1599 else 1600 $query_order = empty($query) ? "NONE" : join(" ", $query); 1601 foreach ($this->unparse_columns as $k => $v) { 1602 if ($v) 1603 $query_order .= " show:$k"; 1604 } 1605 $query_order .= " show:autoassignment"; 1606 $search = new PaperSearch($this->user, ["t" => "vis", "q" => $query_order, "reviewer" => $this->astate->reviewer]); 1607 $plist = new PaperList($search); 1608 $plist->add_column("autoassignment", new AutoassignmentPaperColumn($this)); 1609 $plist->set_table_id_class("foldpl", "pltable_full"); 1610 echo $plist->table_html("reviewers", ["nofooter" => 1]); 1611 1612 if (count(array_intersect_key($deltarev->bypc, $this->conf->pc_members()))) { 1613 $summary = []; 1614 $tagger = new Tagger($this->user); 1615 $nrev = new AssignmentCountSet($this->conf); 1616 $deltarev->rev && $nrev->load_rev(); 1617 $deltarev->lead && $nrev->load_lead(); 1618 $deltarev->shepherd && $nrev->load_shepherd(); 1619 foreach ($this->conf->pc_members() as $p) 1620 if ($deltarev->get($p->contactId)->ass) { 1621 $t = '<div class="ctelt"><div class="ctelti'; 1622 if (($k = $p->viewable_color_classes($this->user))) 1623 $t .= ' ' . $k; 1624 $t .= '"><span class="taghl">' . $this->user->name_html_for($p) . "</span>: " 1625 . plural($deltarev->get($p->contactId)->ass, "assignment") 1626 . self::review_count_report($nrev, $deltarev, $p, "After assignment: ") 1627 . "<hr class=\"c\" /></div></div>"; 1628 $summary[] = $t; 1629 } 1630 if (!empty($summary)) 1631 echo "<div class=\"g\"></div>\n", 1632 "<h3>Summary</h3>\n", 1633 '<div class="pc_ctable">', join("", $summary), "</div>\n"; 1634 } 1635 } 1636 1637 function unparse_csv() { 1638 $this->set_my_conflicts(); 1639 $acsv = new AssignmentCsv; 1640 foreach ($this->assigners as $assigner) 1641 if (($x = $assigner->unparse_csv($this, $acsv))) { 1642 if (isset($x[0])) { 1643 foreach ($x as $elt) 1644 $acsv->add($elt); 1645 } else 1646 $acsv->add($x); 1647 } 1648 $acsv->header = array_keys($acsv->header); 1649 return $acsv; 1650 } 1651 1652 function prow($pid) { 1653 return $this->astate->prow($pid); 1654 } 1655 1656 function execute($verbose = false) { 1657 global $Now; 1658 if ($this->has_error || empty($this->assigners)) { 1659 if ($verbose && !empty($this->msgs)) 1660 $this->report_errors(); 1661 else if ($verbose) 1662 $this->conf->warnMsg("Nothing to assign."); 1663 return !$this->has_error; // true means no errors 1664 } 1665 1666 // mark activity now to avoid DB errors later 1667 $this->user->mark_activity(); 1668 1669 // create new contacts, collect pids 1670 $locks = array("ContactInfo" => "read", "Paper" => "read", "PaperConflict" => "read"); 1671 $this->conf->save_logs(true); 1672 $pids = []; 1673 foreach ($this->assigners as $assigner) { 1674 if (($u = $assigner->contact) && $u->contactId < 0) { 1675 $assigner->contact = $this->astate->register_user($u); 1676 $assigner->cid = $assigner->contact->contactId; 1677 } 1678 $assigner->add_locks($this, $locks); 1679 if ($assigner->pid > 0) 1680 $pids[$assigner->pid] = true; 1681 } 1682 1683 // execute assignments 1684 $tables = array(); 1685 foreach ($locks as $t => $type) 1686 $tables[] = "$t $type"; 1687 $this->conf->qe("lock tables " . join(", ", $tables)); 1688 $this->cleanup_callbacks = $this->cleanup_notify_tracker = []; 1689 $this->qe_stager = null; 1690 1691 foreach ($this->assigners as $assigner) 1692 $assigner->execute($this); 1693 1694 if ($this->qe_stager) 1695 call_user_func($this->qe_stager, null); 1696 $this->conf->qe("unlock tables"); 1697 $this->conf->save_logs(false); 1698 1699 // confirmation message 1700 if ($verbose) { 1701 if ($this->conf->setting("pcrev_assigntime") == $Now) 1702 $this->conf->confirmMsg("Assignments saved! You may want to <a href=\"" . hoturl("mail", "template=newpcrev") . "\">send mail about the new assignments</a>."); 1703 else 1704 $this->conf->confirmMsg("Assignments saved!"); 1705 } 1706 1707 // clean up 1708 foreach ($this->assigners as $assigner) 1709 $assigner->cleanup($this); 1710 foreach ($this->cleanup_callbacks as $cb) 1711 call_user_func($cb[0], $this, $cb[1]); 1712 if (!empty($this->cleanup_notify_tracker) 1713 && $this->conf->opt("trackerCometSite")) 1714 MeetingTracker::contact_tracker_comet($this->conf, array_keys($this->cleanup_notify_tracker)); 1715 if (!empty($pids)) 1716 $this->conf->update_autosearch_tags(array_keys($pids)); 1717 1718 return true; 1719 } 1720 1721 function stage_qe($query /* ... */) { 1722 $this->stage_qe_apply($query, array_slice(func_get_args(), 1)); 1723 } 1724 function stage_qe_apply($query, $args) { 1725 if (!$this->qe_stager) 1726 $this->qe_stager = Dbl::make_multi_qe_stager($this->conf->dblink); 1727 call_user_func($this->qe_stager, $query, $args); 1728 } 1729 1730 function cleanup_callback($name, $func, $arg = null) { 1731 if (!isset($this->cleanup_callbacks[$name])) 1732 $this->cleanup_callbacks[$name] = [$func, null]; 1733 if (func_num_args() > 2) 1734 $this->cleanup_callbacks[$name][1][] = $arg; 1735 } 1736 function cleanup_update_rights() { 1737 $this->cleanup_callback("update_rights", "Contact::update_rights"); 1738 } 1739 function cleanup_notify_tracker($pid) { 1740 $this->cleanup_notify_tracker[$pid] = true; 1741 } 1742 1743 private static function _review_count_link($count, $word, $pl, $prefix, $pc) { 1744 $word = $pl ? plural($count, $word) : $count . " " . $word; 1745 if ($count == 0) 1746 return $word; 1747 return '<a class="qq" href="' . hoturl("search", "q=" . urlencode("$prefix:$pc->email")) 1748 . '">' . $word . "</a>"; 1749 } 1750 1751 private static function _review_count_report_one($ct, $pc) { 1752 $t = self::_review_count_link($ct->rev, "review", true, "re", $pc); 1753 $x = array(); 1754 if ($ct->meta != 0) 1755 $x[] = self::_review_count_link($ct->meta, "meta", false, "meta", $pc); 1756 if ($ct->pri != $ct->rev && (!$ct->meta || $ct->meta != $ct->rev)) 1757 $x[] = self::_review_count_link($ct->pri, "primary", false, "pri", $pc); 1758 if ($ct->sec != 0 && $ct->sec != $ct->rev && $ct->pri + $ct->sec != $ct->rev) 1759 $x[] = self::_review_count_link($ct->sec, "secondary", false, "sec", $pc); 1760 if (!empty($x)) 1761 $t .= " (" . join(", ", $x) . ")"; 1762 return $t; 1763 } 1764 1765 static function review_count_report($nrev, $deltarev, $pc, $prefix) { 1766 $data = []; 1767 $ct = $nrev->get($pc->contactId); 1768 $deltarev && ($ct = $ct->add($deltarev->get($pc->contactId))); 1769 if (!$deltarev || $deltarev->rev) 1770 $data[] = self::_review_count_report_one($ct, $pc); 1771 if ($deltarev && $deltarev->lead) 1772 $data[] = self::_review_count_link($ct->lead, "lead", true, "lead", $pc); 1773 if ($deltarev && $deltarev->shepherd) 1774 $data[] = self::_review_count_link($ct->shepherd, "shepherd", true, "shepherd", $pc); 1775 return '<span class="pcrevsum">' . $prefix . join(", ", $data) . "</span>"; 1776 } 1777 1778 static function run($contact, $text, $forceShow = null) { 1779 $aset = new AssignmentSet($contact, $forceShow); 1780 $aset->parse($text); 1781 return $aset->execute(); 1782 } 1783} 1784 1785 1786class AutoassignmentPaperColumn extends PaperColumn { 1787 private $aset; 1788 function __construct(AssignmentSet $aset) { 1789 parent::__construct($aset->conf, ["name" => "autoassignment", "row" => true, "className" => "pl_autoassignment"]); 1790 $this->aset = $aset; 1791 } 1792 function header(PaperList $pl, $is_text) { 1793 return "Assignment"; 1794 } 1795 function content(PaperList $pl, PaperInfo $row) { 1796 return $this->aset->unparse_paper_assignment($row); 1797 } 1798} 1799