added CRUD for Projects,

added set project for report
This commit is contained in:
2026-08-22 23:48:15 +02:00
parent 369b3b7728
commit b747b586b3
17 changed files with 1118 additions and 65 deletions
+184 -20
View File
@@ -8,6 +8,7 @@ use TPsoft\APIlite\APIlite;
use TPsoft\BugreportBackend\Models\Reports;
use TPsoft\BugreportBackend\Models\Attachments;
use TPsoft\BugreportBackend\Models\Options;
use TPsoft\BugreportBackend\Models\Projects;
class API extends APIlite
@@ -18,21 +19,22 @@ class API extends APIlite
* @param string $title
* @param string $description
* @param int $status 0 = Uncategorized, 1 = Waiting, 2 = InProgress, 3 = Blocked, 4 = Archived
* @param string $group
* @param int $project_id
* @param int $priority 0 = Low, 1 = Medium, 2 = High, 3 = Urgent
*
* @return int
*/
public function add(string $title, string $description, int $status = 0, ?string $group = null, int $priority = 0): int
public function add(string $title, string $description, int $status = 0, ?int $project_id = null, int $priority = 0): int
{
$status = intval($status);
$priority = intval($priority);
$project_id = $this->resolveProjectId($project_id);
$reports = new Reports();
$report_id = $reports->report(null, [
'report_title' => $title,
'report_description' => $description,
'report_status' => $status,
'report_group' => $group,
'project_id' => $project_id,
'report_priority' => $priority,
]);
return $report_id;
@@ -48,6 +50,12 @@ class API extends APIlite
*/
public function update(int $report_id, array $report_data): bool
{
if (array_key_exists('report_group', $report_data)) {
throw new \InvalidArgumentException('Field "report_group" is no longer supported');
}
if (array_key_exists('project_id', $report_data)) {
$report_data['project_id'] = $this->resolveProjectId(intval($report_data['project_id']));
}
$reports = new Reports();
$suc = $reports->report($report_id, $report_data);
return $suc !== false;
@@ -77,7 +85,9 @@ class API extends APIlite
public function get(int $report_id): array
{
$reports = new Reports();
return $reports->report($report_id);
$report = $reports->reportWithProject($report_id);
if ($report === false) throw new \InvalidArgumentException('Report not found');
return $report;
}
/**
@@ -85,20 +95,17 @@ class API extends APIlite
*
* @param array $status 0 = Uncategorized, 1 = Waiting, 2 = InProgress, 3 = Blocked, 4 = Archived
* @param int $page Pagination from 0
* @param int $project_id Optional project filter
*
* @return array
*/
public function getAll(?array $status = null, int $page = 0): array
public function getAll(?array $status = null, int $page = 0, ?int $project_id = null): array
{
$page = intval($page);
$reports = new Reports();
if ($status === null) $status = array(0, 1, 2, 3);
$ret = $reports->search('reports')
->where(['report_status' => $status])
->order(array('report_priority' => 'DESC', 'ordnum' => 'ASC'))
->limit($page * 10, 10)
->toArray();
return $ret;
if (!is_null($project_id)) $this->requireProject($project_id, false);
return $reports->getListWithProjects($status, $page, $project_id);
}
/**
@@ -106,13 +113,14 @@ class API extends APIlite
*
* @param array $status 0 = Uncategorized, 1 = Waiting, 2 = InProgress, 3 = Blocked, 4 = Archived
* @param int $page Pagination from 0
* @param int $project_id Optional project filter
*
* @return array
*/
public function getAllGrouped(?array $status = null, int $page = 0): array
public function getAllGrouped(?array $status = null, int $page = 0, ?int $project_id = null): array
{
$page = intval($page);
$all = $this->getAll($status, $page);
$all = $this->getAll($status, $page, $project_id);
$groups = [];
foreach ($all as $report) {
$groups[$report['report_status']][] = $report;
@@ -124,19 +132,16 @@ class API extends APIlite
* Get archived reports
*
* @param int $page Pagination from 0
* @param int $project_id Optional project filter
*
* @return array
*/
public function getArchived(int $page = 0): array
public function getArchived(int $page = 0, ?int $project_id = null): array
{
$page = intval($page);
if (!is_null($project_id)) $this->requireProject($project_id, false);
$reports = new Reports();
$ret = $reports->search('reports')
->where(['report_status' => 4])
->order(array('created_dt' => 'DESC'))
->limit($page * 10, 10)
->toArray();
return $ret;
return $reports->getListWithProjects(array(4), $page, $project_id, true);
}
/**
@@ -171,6 +176,165 @@ class API extends APIlite
return $suc !== false;
}
/**
* Get projects
*
* @param bool $include_archived Include archived projects
*
* @return array
*/
public function projectGetAll(bool $include_archived = false): array
{
$projects = new Projects();
return $projects->getListWithReportCount($include_archived);
}
/**
* Get project
*
* @param int $project_id
*
* @return array
*/
public function projectGet(int $project_id): array
{
return $this->requireProject($project_id, false);
}
/**
* Add project
*
* @param string $name
* @param string $code
* @param string $color Hex color in #RRGGBB format
*
* @return int
*/
public function projectAdd(string $name, string $code, string $color): int
{
$name = $this->validateProjectName($name);
$code = $this->validateProjectCode($code);
$color = $this->validateProjectColor($color);
$projects = new Projects();
$project_id = $projects->project(null, array(
'project_name' => $name,
'project_code' => $code,
'project_color' => $color,
'project_active' => 1,
'project_system' => 0,
));
if ($project_id === false) throw new \RuntimeException('Project was not created');
return intval($project_id);
}
/**
* Update project
*
* @param int $project_id
* @param string $name
* @param string $color Hex color in #RRGGBB format
*
* @return bool
*/
public function projectUpdate(int $project_id, string $name, string $color): bool
{
$project = $this->requireEditableProject($project_id);
$name = $this->validateProjectName($name, $project_id);
$color = $this->validateProjectColor($color);
$projects = new Projects();
$suc = $projects->project($project['project_id'], array(
'project_name' => $name,
'project_color' => $color,
));
return $suc !== false;
}
/**
* Archive or restore project
*
* @param int $project_id
* @param bool $archived True to archive, false to restore
*
* @return bool
*/
public function projectArchive(int $project_id, bool $archived = true): bool
{
$project = $this->requireEditableProject($project_id);
$projects = new Projects();
$suc = $projects->project($project['project_id'], array(
'project_active' => $archived ? 0 : 1,
));
return $suc !== false;
}
private function resolveProjectId(?int $project_id = null): int
{
if (is_null($project_id)) {
$projects = new Projects();
$project = $projects->projectBy('project_code', 'unassigned');
if ($project === false) throw new \RuntimeException('Unassigned project not found');
return intval($project['project_id']);
}
$project = $this->requireProject($project_id);
return intval($project['project_id']);
}
private function requireProject(int $project_id, bool $require_active = true): array
{
$projects = new Projects();
$project = $projects->project($project_id);
if ($project === false) throw new \InvalidArgumentException('Project not found');
if ($require_active && intval($project['project_active']) !== 1) {
throw new \InvalidArgumentException('Archived project cannot be assigned');
}
return $project;
}
private function requireEditableProject(int $project_id): array
{
$project = $this->requireProject($project_id, false);
if (intval($project['project_system']) === 1) {
throw new \InvalidArgumentException('System project cannot be changed');
}
return $project;
}
private function validateProjectName(string $name, ?int $project_id = null): string
{
$name = trim($name);
if (strlen($name) <= 0 || strlen($name) > 255) {
throw new \InvalidArgumentException('Project name must contain 1 to 255 characters');
}
$projects = new Projects();
$existing = $projects->projectBy('project_name', $name);
if ($existing !== false && intval($existing['project_id']) !== intval($project_id)) {
throw new \InvalidArgumentException('Project name already exists');
}
return $name;
}
private function validateProjectCode(string $code): string
{
$code = strtolower(trim($code));
if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $code)) {
throw new \InvalidArgumentException('Project code has invalid format');
}
$projects = new Projects();
if ($projects->projectBy('project_code', $code) !== false) {
throw new \InvalidArgumentException('Project code already exists');
}
return $code;
}
private function validateProjectColor(string $color): string
{
$color = strtoupper(trim($color));
if (!preg_match('/^#[0-9A-F]{6}$/', $color)) {
throw new \InvalidArgumentException('Project color must use #RRGGBB format');
}
return $color;
}
/**
* Add report attachment
*
+1
View File
@@ -11,6 +11,7 @@ if (Configuration::DB_TYPE == 'mysql') {
$dbh = new DBmodel(sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', Configuration::DB_HOST, Configuration::DB_NAME), Configuration::DB_USER, Configuration::DB_PASS);
} else if (Configuration::DB_TYPE == 'sqlite') {
$dbh = new DBmodel(sprintf('sqlite:%s', Configuration::DB_FILEPATH));
$dbh->query('PRAGMA foreign_keys = ON');
} else {
throw new Exception('Unknown database type');
}
+123
View File
@@ -41,6 +41,129 @@ class Maintenance extends \TPsoft\DBmodel\Maintenance
$this->dbver(3);
$dbver = 3;
}
if ($dbver == 3) {
$this->migrateProjects();
$dbver = 4;
}
}
private function migrateProjects(): void
{
$db_type = $this->dbh->getDBtype();
if ($db_type == 'sqlite') {
$this->migrateProjectsSqlite();
return;
}
if ($db_type == 'mysql') {
$this->migrateProjectsMysql();
return;
}
throw new \Exception('Unknown DB type: ' . $db_type);
}
private function migrateProjectsSqlite(): void
{
$pdo = $this->dbh->dbh;
$pdo->beginTransaction();
try {
$this->queryOrFail('
CREATE TABLE `projects` (
`project_id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`project_name` VARCHAR(255) COLLATE NOCASE NOT NULL UNIQUE,
`project_code` VARCHAR(64) NOT NULL UNIQUE,
`project_color` VARCHAR(7) NOT NULL,
`project_active` INTEGER NOT NULL DEFAULT 1,
`project_system` INTEGER NOT NULL DEFAULT 0,
`created_dt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_dt` DATETIME DEFAULT NULL
)
');
$this->insertUnassignedProject();
$unassigned_id = $this->unassignedProjectId();
$this->queryOrFail('
CREATE TABLE `reports_v4` (
`report_id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`report_title` VARCHAR(255) DEFAULT NULL,
`report_description` TEXT DEFAULT NULL,
`report_status` INTEGER DEFAULT NULL,
`project_id` INTEGER NOT NULL,
`report_priority` INTEGER DEFAULT NULL,
`ordnum` INTEGER DEFAULT NULL,
`created_dt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE RESTRICT
)
');
$this->queryOrFail(sprintf(
'INSERT INTO `reports_v4` (`report_id`, `report_title`, `report_description`, `report_status`, `project_id`, `report_priority`, `ordnum`, `created_dt`)
SELECT `report_id`, `report_title`, `report_description`, `report_status`, %d, `report_priority`, `ordnum`, `created_dt` FROM `reports`',
$unassigned_id
));
$this->queryOrFail('DROP TABLE `reports`');
$this->queryOrFail('ALTER TABLE `reports_v4` RENAME TO `reports`');
$this->queryOrFail('CREATE INDEX `reports_project_id` ON `reports` (`project_id`)');
$this->dbver(4);
$pdo->commit();
} catch (\Throwable $error) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $error;
}
}
private function migrateProjectsMysql(): void
{
$this->queryOrFail('
CREATE TABLE `projects` (
`project_id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`project_name` VARCHAR(255) NOT NULL UNIQUE,
`project_code` VARCHAR(64) NOT NULL UNIQUE,
`project_color` VARCHAR(7) NOT NULL,
`project_active` TINYINT(1) NOT NULL DEFAULT 1,
`project_system` TINYINT(1) NOT NULL DEFAULT 0,
`created_dt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_dt` DATETIME DEFAULT NULL
) ENGINE=InnoDB
');
$this->insertUnassignedProject();
$unassigned_id = $this->unassignedProjectId();
$this->queryOrFail('ALTER TABLE `reports` ADD `project_id` INT DEFAULT NULL AFTER `report_status`');
$this->queryOrFail(sprintf('UPDATE `reports` SET `project_id` = %d', $unassigned_id));
$this->queryOrFail('ALTER TABLE `reports` MODIFY `project_id` INT NOT NULL');
$this->queryOrFail('ALTER TABLE `reports` DROP COLUMN `report_group`');
$this->queryOrFail('CREATE INDEX `reports_project_id` ON `reports` (`project_id`)');
$this->queryOrFail('
ALTER TABLE `reports`
ADD CONSTRAINT `reports_project_id_fk`
FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE RESTRICT
');
$this->dbver(4);
}
private function insertUnassignedProject(): void
{
$this->queryOrFail(sprintf(
'INSERT INTO `projects` (`project_name`, `project_code`, `project_color`, `project_active`, `project_system`, `created_dt`)
VALUES (%s, %s, %s, 1, 1, %s)',
$this->dbh->quote('Nezaradené'),
$this->dbh->quote('unassigned'),
$this->dbh->quote('#797979'),
$this->dbh->quote(date('Y-m-d H:i:s'))
));
}
private function unassignedProjectId(): int
{
return intval($this->dbh->getOne(sprintf(
'SELECT `project_id` FROM `projects` WHERE `project_code` = %s',
$this->dbh->quote('unassigned')
)));
}
private function queryOrFail(string $query): void
{
if ($this->dbh->query($query) === false) {
throw new \RuntimeException($this->dbh->errorMessage());
}
}
protected function settings(string $key, ?string $value = null): string|false
+108
View File
@@ -0,0 +1,108 @@
<?php
/*
TPsoft.org 2000-2026
file for controlers/*.php
Milestones:
2026-08-22 20:39 Created
*/
namespace TPsoft\BugreportBackend\Models;
class Projects extends \TPsoft\DBmodel\DBmodel {
public $tables = array(
'projects' => array(
'name' => 'projects',
'primary_key_name' => 'project_id',
'allow_attributes' => array(
'project_name' => 'VARCHAR(255)',
'project_code' => 'VARCHAR(64)',
'project_color' => 'VARCHAR(7)',
'project_active' => 'INTEGER',
'project_system' => 'INTEGER',
'created_dt' => 'DATETIME',
'updated_dt' => 'DATETIME'
)
),
);
public function exist($primary_key = null) {
return $this->existRecord('projects', $primary_key);
}
public function project($primary_key = null, $data = array()) {
if (is_null($primary_key)
&& !isset($data['created_dt']))
{
$data['created_dt'] = date('Y-m-d H:i:s');
}
if (!is_null($primary_key)
&& is_array($data)
&& count($data) > 0
&& !isset($data['updated_dt']))
{
$data['updated_dt'] = date('Y-m-d H:i:s');
}
return $this->record('projects', $primary_key, $data);
}
public function projectBy($colname, $colvalue) {
return $this->recordBy('projects', $colname, $colvalue);
}
public function projectSave($data = array()) {
return $this->project($this->exist($data) ? $data : null, $data);
}
public function projectEmpty() {
return $this->recordEmpty('projects');
}
public function projectAttributes() {
return $this->typesAttributes('projects');
}
public function projectCount() {
return $this->count('projects');
}
public function getList($search = array(), $reverse = false, $concat_or = false) {
return $this->search('projects')
->where($search, $concat_or)
->order(array('project_id' => $reverse ? 'DESC' : 'ASC'))
->toArray();
}
public function getListOrganize($cola_name, $search = array(), $reverse = false, $concat_or = false) {
$all = $this->getList($search, $reverse, $concat_or);
$ret = array();
if (is_array($all)) foreach ($all as $key => $row) {
$ret[$row[$cola_name]] = $row;
}
return $ret;
}
public function getListByID($search = array(), $reverse = false, $concat_or = false) {
return $this->getListOrganize('project_id', $search, $reverse, $concat_or);
}
public function projectCombo($col_key, $col_value, $add_empty = false) {
return $this->search('projects')
->toCombo($col_key, $col_value, $add_empty);
}
public function getListWithReportCount($include_archived = false) {
$where = $include_archived ? '' : ' WHERE `project_active` = 1';
return $this->getAll(
'SELECT `projects`.*,'
. ' (SELECT COUNT(*) FROM `reports` WHERE `reports`.`project_id` = `projects`.`project_id`) AS `report_count`'
. ' FROM `projects`'
. $where
. ' ORDER BY `project_active` DESC, `project_system` DESC, `project_name` ASC'
);
}
}
?>
+40 -1
View File
@@ -19,7 +19,7 @@ class Reports extends \TPsoft\DBmodel\DBmodel {
'report_title' => 'VARCHAR(255)',
'report_description' => 'TEXT',
'report_status' => 'INTEGER',
'report_group' => 'VARCHAR(255)',
'project_id' => 'INTEGER',
'report_priority' => 'INTEGER',
'created_dt' => 'DATETIME',
'ordnum' => 'INTEGER'
@@ -85,6 +85,45 @@ class Reports extends \TPsoft\DBmodel\DBmodel {
->toCombo($col_key, $col_value, $add_empty);
}
public function reportWithProject($report_id) {
$this->import(new Projects());
return $this->getRow(sprintf(
'SELECT `reports`.*, `projects`.`project_name`, `projects`.`project_code`,'
. ' `projects`.`project_color`, `projects`.`project_active`'
. ' FROM %s'
. ' INNER JOIN %s ON `projects`.`project_id` = `reports`.`project_id`'
. ' WHERE `reports`.`report_id` = %d LIMIT 1',
$this->tables['reports']['name'],
$this->tables['projects']['name'],
intval($report_id)
));
}
public function getListWithProjects($status, $page = 0, $project_id = null, $archived = false) {
$status = array_map('intval', $status);
if (count($status) <= 0) return array();
$where = '`reports`.`report_status` IN (' . implode(', ', $status) . ')';
if (!is_null($project_id)) {
$where .= ' AND `reports`.`project_id` = ' . intval($project_id);
}
$order = $archived
? '`reports`.`created_dt` DESC'
: '`reports`.`report_priority` DESC, `reports`.`ordnum` ASC';
$this->import(new Projects());
return $this->getAll(sprintf(
'SELECT `reports`.*, `projects`.`project_name`, `projects`.`project_code`,'
. ' `projects`.`project_color`, `projects`.`project_active`'
. ' FROM %s'
. ' INNER JOIN %s ON `projects`.`project_id` = `reports`.`project_id`'
. ' WHERE %s ORDER BY %s LIMIT %d, 10',
$this->tables['reports']['name'],
$this->tables['projects']['name'],
$where,
$order,
intval($page) * 10
));
}
}
?>