Allow projects to auto-assign managers and reviewers

This commit is contained in:
Alex Cabal 2024-12-18 20:57:21 -06:00
parent e51cc4395e
commit 4596aeb007
12 changed files with 116 additions and 21 deletions

View file

@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS `Benefits` (
`CanManageProjects` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanReviewProjects` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanEditProjects` tinyint(1) unsigned NOT NULL DEFAULT 0,
`CanBeAutoAssignedToProjects` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`UserId`),
KEY `idxBenefits` (`CanAccessFeeds`,`CanVote`,`CanBulkDownload`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,5 @@
CREATE TABLE `ProjectUnassignedUsers` (
`UserId` int(10) unsigned NOT NULL,
`Role` enum('manager','reviewer') NOT NULL,
UNIQUE KEY `idxUnique` (`Role`,`UserId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View file

@ -21,6 +21,7 @@ class Benefits{
public bool $CanManageProjects = false;
public bool $CanReviewProjects = false;
public bool $CanEditProjects = false;
public bool $CanBeAutoAssignedToProjects = false;
protected bool $_HasBenefits;
@ -45,6 +46,8 @@ class Benefits{
$this->CanReviewProjects
||
$this->CanEditProjects
||
$this->CanBeAutoAssignedToProjects
){
return true;
}

View file

@ -0,0 +1,7 @@
<?
namespace Enums;
enum ProjectRoleType: string{
case Manager = 'manager';
case Reviewer = 'reviewer';
}

View file

@ -183,10 +183,10 @@ final class Project{
/**
* @throws Exceptions\InvalidProjectException If the `Project` is invalid.
*/
public function Validate(): void{
public function Validate(bool $allowUnsetEbookId = false, bool $allowUnsetRoles = false): void{
$error = new Exceptions\InvalidProjectException();
if(!isset($this->EbookId)){
if(!$allowUnsetEbookId && !isset($this->EbookId)){
$error->Add(new Exceptions\EbookRequiredException());
}
@ -235,10 +235,10 @@ final class Project{
}
}
if(!isset($this->ManagerUserId)){
if(!$allowUnsetRoles && !isset($this->ManagerUserId)){
$error->Add(new Exceptions\ManagerRequiredException());
}
else{
elseif(isset($this->ManagerUserId)){
try{
$this->_Manager = User::Get($this->ManagerUserId);
}
@ -247,10 +247,10 @@ final class Project{
}
}
if(!isset($this->ReviewerUserId)){
$error->Add(new Exceptions\ManagerRequiredException());
if(!$allowUnsetRoles && !isset($this->ReviewerUserId)){
$error->Add(new Exceptions\ReviewerRequiredException());
}
else{
elseif(isset($this->ReviewerUserId)){
try{
$this->_Reviewer = User::Get($this->ReviewerUserId);
}
@ -269,12 +269,39 @@ final class Project{
}
/**
* Creates a new `Project`. If `Project::$Manager` or `Project::$Reviewer` are unassigned, they will be automatically assigned.
*
* @throws Exceptions\InvalidProjectException If the `Project` is invalid.
* @throws Exceptions\EbookIsNotAPlaceholderException If the `Project`'s `Ebook` is not a placeholder.
* @throws Exceptions\ProjectExistsException If the `Project`'s `Ebook` already has an active `Project`.
* @throws Exceptions\UserNotFoundException If a manager or reviewer could not be auto-assigned.
*/
public function Create(): void{
$this->Validate();
if(!isset($this->ManagerUserId)){
try{
$this->Manager = User::GetByAvailableForProjectAssignment(Enums\ProjectRoleType::Manager, null);
}
catch(Exceptions\UserNotFoundException){
throw new Exceptions\UserNotFoundException('Could not auto-assign a suitable manager.');
}
$this->ManagerUserId = $this->Manager->UserId;
}
if(!isset($this->ReviewerUserId)){
try{
$this->Reviewer = User::GetByAvailableForProjectAssignment(Enums\ProjectRoleType::Reviewer, $this->Manager->UserId);
}
catch(Exceptions\UserNotFoundException){
unset($this->Manager);
unset($this->ManagerUserId);
throw new Exceptions\UserNotFoundException('Could not auto-assign a suitable reviewer.');
}
$this->ReviewerUserId = $this->Reviewer->UserId;
}
$this->Validate(false, true);
try{
$this->FetchLastDiscussionTimestamp();

View file

@ -401,6 +401,46 @@ class User{
', [], User::class);
}
/**
* Get a random `User` who is available to be assigned to the given role.
*
* @param Enums\ProjectRoleType $role The role to select for.
* @param int $excludedUserId Don't include this `UserId` when selecting; `null` to not exclude any users.
*
* @throws Exceptions\UserNotFoundException If no `User` is available to be assigned to a `Project`.
*/
public static function GetByAvailableForProjectAssignment(Enums\ProjectRoleType $role, ?int $excludedUserId): User{
// First, check if there are `User`s available for assignment.
// We use `coalesce()` to allow comparison in case `$excludedUserId` is `null` - there will never be a `UserId` of `0`.
$doUnassignedUsersExist = Db::QueryBool('SELECT exists (select * from ProjectUnassignedUsers where Role = ? and UserId != coalesce(?, 0))', [$role, $excludedUserId]);
// No unassigned `User`s left. Refill the list.
if(!$doUnassignedUsersExist){
Db::Query('
INSERT ignore
into ProjectUnassignedUsers
(UserId, Role)
select
Users.UserId,
?
from Users
inner join Benefits
using (UserId)
where
Benefits.CanManageProjects = true
and Benefits.CanBeAutoAssignedToProjects = true
', [$role]);
}
// Now, select a random `User`.
$user = Db::Query('SELECT u.* from Users u inner join ProjectUnassignedUsers puu using (UserId) where Role = ? and UserId != coalesce(?, 0) order by rand()', [$role, $excludedUserId], User::class)[0] ?? throw new Exceptions\UserNotFoundException();
// Delete the `User` we just got from the unassigned users list.
Db::Query('DELETE from ProjectUnassignedUsers where UserId = ? and Role = ?', [$user->UserId, $role]);
return $user;
}
/**
* Get a `User` if they are considered "registered".
*

View file

@ -105,7 +105,7 @@ $isEditForm = $isEditForm ?? false;
<span>Type</span>
<span>
<select name="type-collection-name-1">
<option value=""></option>
<option value="">&#160;</option>
<option value="<?= Enums\CollectionType::Series->value ?>"<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 0 && $ebook->CollectionMemberships[0]->Collection->Type == Enums\CollectionType::Series){ ?> selected="selected"<? } ?>>Series</option>
<option value="<?= Enums\CollectionType::Set->value ?>"<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 0 && $ebook->CollectionMemberships[0]->Collection->Type == Enums\CollectionType::Set){ ?> selected="selected"<? } ?>>Set</option>
</select>
@ -139,7 +139,7 @@ $isEditForm = $isEditForm ?? false;
<span>Type</span>
<span>
<select name="type-collection-name-2">
<option value=""></option>
<option value="">&#160;</option>
<option value="<?= Enums\CollectionType::Series->value ?>"<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 1 && $ebook->CollectionMemberships[1]->Collection->Type == Enums\CollectionType::Series){ ?> selected="selected"<? } ?>>Series</option>
<option value="<?= Enums\CollectionType::Set->value ?>"<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 1 && $ebook->CollectionMemberships[1]->Collection->Type == Enums\CollectionType::Set){ ?> selected="selected"<? } ?>>Set</option>
</select>
@ -171,7 +171,7 @@ $isEditForm = $isEditForm ?? false;
<span>Type</span>
<span>
<select name="type-collection-name-3">
<option value=""></option>
<option value="">&#160;</option>
<option value="<?= Enums\CollectionType::Series->value ?>"<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 2 && $ebook->CollectionMemberships[2]->Collection->Type == Enums\CollectionType::Series){ ?> selected="selected"<? } ?>>Series</option>
<option value="<?= Enums\CollectionType::Set->value ?>"<? if(isset($ebook->CollectionMemberships) && sizeof($ebook->CollectionMemberships) > 2 && $ebook->CollectionMemberships[2]->Collection->Type == Enums\CollectionType::Set){ ?> selected="selected"<? } ?>>Set</option>
</select>
@ -232,7 +232,7 @@ $isEditForm = $isEditForm ?? false;
<span>Difficulty</span>
<span>
<select name="ebook-placeholder-difficulty">
<option value=""></option>
<option value="">&#160;</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Beginner->value ?>"<? if($ebook->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Beginner){ ?> selected="selected"<? } ?>>Beginner</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Intermediate->value ?>"<? if($ebook->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Intermediate){ ?> selected="selected"<? } ?>>Intermediate</option>
<option value="<?= Enums\EbookPlaceholderDifficulty::Advanced->value ?>"<? if($ebook->EbookPlaceholder?->Difficulty == Enums\EbookPlaceholderDifficulty::Advanced){ ?> selected="selected"<? } ?>>Advanced</option>

View file

@ -32,8 +32,14 @@ $isEditForm = $isEditForm ?? false;
<label class="icon user">
<span>Manager</span>
<? if(!$isEditForm){ ?>
<span>Leave blank to auto-assign.</span>
<? } ?>
<span>
<select name="project-manager-user-id">
<? if(!$isEditForm){ ?>
<option value="">&#160;</option>
<? } ?>
<? foreach($managers as $manager){ ?>
<option value="<?= $manager->UserId ?>"<? if(isset($project->ManagerUserId) && $project->ManagerUserId == $manager->UserId){ ?> selected="selected"<? } ?>><?= Formatter::EscapeHtml($manager->Name) ?></option>
<? } ?>
@ -43,8 +49,14 @@ $isEditForm = $isEditForm ?? false;
<label class="icon user">
<span>Reviewer</span>
<? if(!$isEditForm){ ?>
<span>Leave blank to auto-assign.</span>
<? } ?>
<span>
<select name="project-reviewer-user-id">
<? if(!$isEditForm){ ?>
<option value="">&#160;</option>
<? } ?>
<? foreach($reviewers as $reviewer){ ?>
<option value="<?= $reviewer->UserId ?>"<? if(isset($project->ReviewerUserId) && $project->ReviewerUserId == $reviewer->UserId){ ?> selected="selected"<? } ?>><?= Formatter::EscapeHtml($reviewer->Name) ?></option>
<? } ?>
@ -65,7 +77,7 @@ $isEditForm = $isEditForm ?? false;
</label>
<label>
<span>Automatically update status?</span>
<span>Automatically update status</span>
<input type="hidden" name="project-is-status-automatically-updated" value="false" />
<input
type="checkbox"

View file

@ -1899,11 +1899,11 @@ label:has(select) > span{
display: block;
}
label:has(select) > span + span{
label:has(select) > span + span:last-child{
display: inline-block;
}
label:has(select) > span + span::after{
label:has(select) > span + span:last-child::after{
display: block;
font-style: normal;
position: absolute;
@ -1927,7 +1927,7 @@ label:has(select):hover > span + span::after{
transition: none;
}
label:has(select) > span + span,
label:has(select) > span + span:last-child,
label:has(input[type="email"]),
label:has(input[type="search"]),
label:has(input[type="url"]),
@ -4069,7 +4069,7 @@ nav.breadcrumbs + h1{
.ebook-carousel img,
.ebook-carousel a:active img,
ol.ebooks-list > li a[tabindex]:active,
label:has(select) > span + span::after,
label:has(select) > span + span:last-child::after,
label:has(input[type="search"]):focus-within::before,
label:has(input[type="search"]):hover::before,
label:has(select):hover > span + span::after,

View file

@ -17,7 +17,7 @@ try{
throw new Exceptions\EbookNotFoundException();
}
if($isSaved){
if($isSaved || $isProjectSaved){
session_unset();
}
}

View file

@ -28,8 +28,7 @@ try{
$project = new Project();
$project->FillFromHttpPost();
$project->Started = NOW;
$project->EbookId = 0; // Dummy value to pass validation, we'll set it to the real value before creating the `Project`.
$project->Validate();
$project->Validate(true, true);
}
try{
@ -86,7 +85,7 @@ catch(Exceptions\LoginRequiredException){
catch(Exceptions\InvalidPermissionsException | Exceptions\InvalidHttpMethodException | Exceptions\HttpMethodNotAllowedException){
Template::ExitWithCode(Enums\HttpCode::Forbidden);
}
catch(Exceptions\AppException $ex){
catch(Exceptions\InvalidEbookException | Exceptions\InvalidProjectException $ex){
$_SESSION['ebook'] = $ebook;
$_SESSION['exception'] = $ex;

View file

@ -17,6 +17,7 @@ try{
// POSTing a new `Project`.
if($httpMethod == Enums\HttpMethod::Post){
$project = new Project();
$project->FillFromHttpPost();
$project->Create();