1490 lines
63 KiB
PHP
1490 lines
63 KiB
PHP
<?php
|
|
|
|
class DataSource {
|
|
private $pdo;
|
|
private $uploadDir;
|
|
private $columnExistenceCache = [];
|
|
private const DEFAULT_FILE_RULES = [
|
|
'extensions' => ['csv', 'json', 'pdf', 'xls', 'xlsx'],
|
|
'mimes' => [
|
|
'text/csv',
|
|
'text/plain',
|
|
'application/json',
|
|
'application/pdf',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
],
|
|
'description' => 'CSV, JSON, PDF, XLS, XLSX',
|
|
];
|
|
private const TYPE_SPECIFIC_RULES = [
|
|
'csv' => [
|
|
'extensions' => ['csv'],
|
|
'mimes' => ['text/csv', 'text/plain', 'application/vnd.ms-excel'],
|
|
'description' => 'CSV files (.csv)',
|
|
],
|
|
'json' => [
|
|
'extensions' => ['json'],
|
|
'mimes' => ['application/json', 'text/json', 'text/plain'],
|
|
'description' => 'JSON files (.json)',
|
|
],
|
|
'pdf' => [
|
|
'extensions' => ['pdf'],
|
|
'mimes' => ['application/pdf'],
|
|
'description' => 'PDF documents (.pdf)',
|
|
],
|
|
'xls' => [
|
|
'extensions' => ['xls'],
|
|
'mimes' => ['application/vnd.ms-excel'],
|
|
'description' => 'Excel 97-2003 (.xls)',
|
|
],
|
|
'xlsx' => [
|
|
'extensions' => ['xlsx'],
|
|
'mimes' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
'description' => 'Excel (.xlsx)',
|
|
],
|
|
'dta' => [
|
|
'extensions' => ['dta'],
|
|
'mimes' => [
|
|
'application/x-stata',
|
|
'application/stata',
|
|
'application/octet-stream',
|
|
],
|
|
'description' => 'Stata dataset (.dta)',
|
|
],
|
|
'stata' => [
|
|
'extensions' => ['dta'],
|
|
'mimes' => [
|
|
'application/x-stata',
|
|
'application/stata',
|
|
'application/octet-stream',
|
|
],
|
|
'description' => 'Stata dataset (.dta)',
|
|
],
|
|
'sav' => [
|
|
'extensions' => ['sav'],
|
|
'mimes' => [
|
|
'application/octet-stream',
|
|
'application/vnd.spss.sav',
|
|
],
|
|
'description' => 'SPSS dataset (.sav)',
|
|
],
|
|
];
|
|
|
|
public function __construct(PDO $pdo) {
|
|
$this->pdo = $pdo;
|
|
|
|
// Correctly define the absolute path from the project root.
|
|
// __DIR__ points to the 'classes' directory.
|
|
// dirname(__DIR__) moves up one level to 'dsp26072025'.
|
|
$projectRoot = dirname(__DIR__);
|
|
$this->uploadDir = $projectRoot . '/uploads/datasources/';
|
|
|
|
// Ensure upload directory exists
|
|
if (!is_dir($this->uploadDir)) {
|
|
if (!mkdir($this->uploadDir, 0777, true)) {
|
|
// If it still fails, there is a deeper permissions issue
|
|
die("Error: Failed to create directory. Please check permissions for " . $this->uploadDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Public getter for upload directory (needed for file deletion)
|
|
public function getUploadDir(): string {
|
|
return $this->uploadDir;
|
|
}
|
|
|
|
// --- Data Source Management (dsps_tbl_datasource) ---
|
|
|
|
/**
|
|
* Adds a new data source.
|
|
*
|
|
* @param int $fkdspstds_id FK to dsps_tbl_typedatasource.
|
|
* @param int $fkdspscate_id FK to dsps_tbl_dspscategory.
|
|
* @param int $fkisp_id_of FK to ist_tbl_people (Data Owner).
|
|
* @param string|null $filename Primary file path/name or API endpoint URL.
|
|
* @param string $title_en English title.
|
|
* @param string|null $title_kh Khmer title.
|
|
* @param string|null $description Description.
|
|
* @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published').
|
|
* @param int $reg_by User ID who registered it.
|
|
* @param string|null $filename1 Optional secondary file path.
|
|
* @param string|null $filename2 Optional tertiary file path.
|
|
* @param string|null $filename3 Optional quaternary file path.
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function addDataSource(
|
|
int $fkdspstds_id,
|
|
int $fkdspscate_id,
|
|
int $fkisp_id_of,
|
|
?string $filename,
|
|
string $title_en,
|
|
?string $title_kh,
|
|
?string $description,
|
|
string $status,
|
|
int $reg_by,
|
|
?string $filename1 = null,
|
|
?string $filename2 = null,
|
|
?string $filename3 = null
|
|
): bool {
|
|
// Determine public_date based on status. If 'Published', set to current date, otherwise NULL.
|
|
$publicDateSql = ($status === 'Published') ? "CURRENT_DATE" : "NULL";
|
|
|
|
$sql = "INSERT INTO dsps_tbl_datasource (fkdspstds_id, fkdspscate_id, fkisp_id_of, dspsds_filename,
|
|
dspsds_title_en, dspsds_title_kh, dspsds_description,
|
|
dspsds_status, dspsds_reg_by, dspsds_public_date,
|
|
dspsds_filename1, dspsds_filename2, dspsds_filename3)
|
|
VALUES (:fkdspstds_id, :fkdspscate_id, :fkisp_id_of, :filename,
|
|
:title_en, :title_kh, :description, :status, :reg_by,
|
|
" . $publicDateSql . ", :filename1, :filename2, :filename3)"; // Directly inject CURRENT_DATE or NULL
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
|
|
$stmt->bindParam(':filename', $filename);
|
|
$stmt->bindParam(':title_en', $title_en);
|
|
$stmt->bindParam(':title_kh', $title_kh);
|
|
$stmt->bindParam(':description', $description);
|
|
$stmt->bindParam(':status', $status);
|
|
$stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT);
|
|
$stmt->bindParam(':filename1', $filename1);
|
|
$stmt->bindParam(':filename2', $filename2);
|
|
$stmt->bindParam(':filename3', $filename3);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error adding data source: " . $e->getMessage());
|
|
throw new Exception("Could not add data source. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates an existing data source.
|
|
*
|
|
* @param int $id ID of the data source to update.
|
|
* @param int $fkdspstds_id FK to dsps_tbl_typedatasource.
|
|
* @param int $fkdspscate_id FK to dsps_tbl_dspscategory.
|
|
* @param int $fkisp_id_of FK to ist_tbl_people (Data Owner).
|
|
* @param string|null $filename File path/name or API endpoint URL.
|
|
* @param string $title_en English title.
|
|
* @param string|null $title_kh Khmer title.
|
|
* @param string|null $description Description.
|
|
* @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published').
|
|
* @param int $mod_by User ID who modified it.
|
|
* @param string|null $filename1 Optional secondary file path.
|
|
* @param string|null $filename2 Optional tertiary file path.
|
|
* @param string|null $filename3 Optional quaternary file path.
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function updateDataSource(
|
|
int $id,
|
|
int $fkdspstds_id,
|
|
int $fkdspscate_id,
|
|
int $fkisp_id_of,
|
|
?string $filename,
|
|
string $title_en,
|
|
?string $title_kh,
|
|
?string $description,
|
|
string $status,
|
|
int $mod_by,
|
|
?string $filename1 = null,
|
|
?string $filename2 = null,
|
|
?string $filename3 = null
|
|
): bool {
|
|
// Check current status to decide on dspsds_public_date update
|
|
$currentDataSource = $this->getDataSourceById($id);
|
|
$publicDateUpdate = "";
|
|
if ($status === 'Published' && ($currentDataSource['dspsds_status'] !== 'Published' || empty($currentDataSource['dspsds_public_date']))) {
|
|
$publicDateUpdate = ", dspsds_public_date = CURRENT_DATE";
|
|
} elseif ($status !== 'Published' && !empty($currentDataSource['dspsds_public_date'])) {
|
|
$publicDateUpdate = ", dspsds_public_date = NULL";
|
|
}
|
|
|
|
$sql = "UPDATE dsps_tbl_datasource
|
|
SET fkdspstds_id = :fkdspstds_id, fkdspscate_id = :fkdspscate_id, fkisp_id_of = :fkisp_id_of,
|
|
dspsds_filename = :filename, dspsds_title_en = :title_en, dspsds_title_kh = :title_kh,
|
|
dspsds_description = :description, dspsds_status = :status,
|
|
dspsds_filename1 = :filename1, dspsds_filename2 = :filename2, dspsds_filename3 = :filename3,
|
|
dspsds_mod_datetime = CURRENT_TIMESTAMP, dspsds_reg_by = :mod_by
|
|
{$publicDateUpdate}
|
|
WHERE pkdspsds_id = :id";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
|
|
$stmt->bindParam(':filename', $filename);
|
|
$stmt->bindParam(':title_en', $title_en);
|
|
$stmt->bindParam(':title_kh', $title_kh);
|
|
$stmt->bindParam(':description', $description);
|
|
$stmt->bindParam(':status', $status);
|
|
$stmt->bindParam(':filename1', $filename1);
|
|
$stmt->bindParam(':filename2', $filename2);
|
|
$stmt->bindParam(':filename3', $filename3);
|
|
$stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT);
|
|
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error updating data source (ID: $id): " . $e->getMessage());
|
|
throw new Exception("Could not update data source. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a data source from the database and its associated file.
|
|
*
|
|
* @param int $id The ID of the data source to delete.
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function deleteDataSource(int $id): bool {
|
|
// Get the filename to delete the actual file
|
|
$dataSource = $this->getDataSourceById($id);
|
|
if ($dataSource) {
|
|
$fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3'];
|
|
foreach ($fileColumns as $column) {
|
|
if (!empty($dataSource[$column])) {
|
|
$filePath = $this->uploadDir . $dataSource[$column];
|
|
if (is_file($filePath)) {
|
|
unlink($filePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$sql = "DELETE FROM dsps_tbl_datasource WHERE pkdspsds_id = :id";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':id', $id);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error deleting data source (ID: $id): " . $e->getMessage());
|
|
throw new Exception("Could not delete data source. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves a single data source by its ID.
|
|
*
|
|
* @param int $id The ID of the data source.
|
|
* @return array|false The data source data as an associative array, or false if not found.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getDataSourceById(int $id) {
|
|
$sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh,
|
|
dspscate.dspscate_title_en AS category_name,
|
|
isp.isp_firstname_en, isp.isp_lastname_en
|
|
FROM dsps_tbl_datasource ds
|
|
JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
|
|
JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
|
|
JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id
|
|
WHERE ds.pkdspsds_id = :id";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':id', $id);
|
|
$stmt->execute();
|
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching data source by ID ($id): " . $e->getMessage());
|
|
throw new Exception("Could not retrieve data source. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves data sources based on various filters.
|
|
*
|
|
* @param int|null $owner_person_id Optional: Filter by data owner's person ID.
|
|
* @param string|null $status Optional: Filter by status (e.g., 'Active', 'Pending Review').
|
|
* @param int|null $category_id Optional: Filter by category ID.
|
|
* @param string|null $search_query Optional: Search by title or description.
|
|
* @param int|null $limit Optional: Limit the number of results.
|
|
* @return array An array of data source data.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getDataSources(
|
|
?int $owner_person_id = null,
|
|
?string $status = null,
|
|
?int $category_id = null,
|
|
?string $search_query = null,
|
|
?int $limit = null
|
|
): array {
|
|
$sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh,
|
|
dspscate.dspscate_title_en AS category_name,
|
|
isp.isp_firstname_en, isp.isp_lastname_en
|
|
FROM dsps_tbl_datasource ds
|
|
JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
|
|
JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
|
|
JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id";
|
|
$conditions = [];
|
|
$params = [];
|
|
|
|
// Use strict checks for null to avoid issues with 0 and empty strings
|
|
if ($owner_person_id !== null) {
|
|
$conditions[] = "ds.fkisp_id_of = :owner_person_id";
|
|
$params[':owner_person_id'] = $owner_person_id;
|
|
}
|
|
if ($status !== null) {
|
|
$conditions[] = "ds.dspsds_status = :status";
|
|
$params[':status'] = $status;
|
|
}
|
|
if ($category_id !== null) {
|
|
$conditions[] = "ds.fkdspscate_id = :category_id";
|
|
$params[':category_id'] = $category_id;
|
|
}
|
|
if ($search_query !== null && $search_query !== '') {
|
|
$conditions[] = "(ds.dspsds_title_en LIKE :search_query OR ds.dspsds_description LIKE :search_query)";
|
|
$params[':search_query'] = '%' . $search_query . '%';
|
|
}
|
|
|
|
if (!empty($conditions)) {
|
|
$sql .= " WHERE " . implode(" AND ", $conditions);
|
|
}
|
|
|
|
$sql .= " ORDER BY ds.dspsds_reg_datetime DESC";
|
|
|
|
// A safer way to handle LIMIT is to validate and concatenate the integer
|
|
// directly, as not all PDO drivers support binding LIMIT values.
|
|
if ($limit !== null && is_int($limit) && $limit > 0) {
|
|
$sql .= " LIMIT " . $limit;
|
|
}
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
// Pass the parameters array directly to execute()
|
|
$stmt->execute($params);
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching data sources: " . $e->getMessage());
|
|
throw new Exception("Could not retrieve data sources. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the total count of data sources.
|
|
*
|
|
* @return int The total number of data sources.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getTotalDataSources(): int {
|
|
$sql = "SELECT COUNT(*) FROM dsps_tbl_datasource";
|
|
try {
|
|
$stmt = $this->pdo->query($sql);
|
|
return $stmt->fetchColumn();
|
|
} catch (PDOException $e) {
|
|
error_log("Error getting total data sources count: " . $e->getMessage());
|
|
throw new Exception("Could not retrieve data source count. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the upload of a data source file.
|
|
*
|
|
* @param array $file The $_FILES array for the uploaded file.
|
|
* @return string The unique filename of the uploaded file.
|
|
* @throws Exception If the upload fails.
|
|
*/
|
|
public function handleDataSourceFileUpload(array $file, ?array $fileRules = null): string {
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
throw new Exception('File upload error: ' . $file['error']);
|
|
}
|
|
|
|
$rules = $fileRules ?? self::DEFAULT_FILE_RULES;
|
|
$allowedMimeTypes = $rules['mimes'] ?? self::DEFAULT_FILE_RULES['mimes'];
|
|
$allowedExtensions = $rules['extensions'] ?? self::DEFAULT_FILE_RULES['extensions'];
|
|
$description = $rules['description'] ?? self::DEFAULT_FILE_RULES['description'];
|
|
if (($file['size'] ?? 0) <= 0) {
|
|
throw new Exception('Uploaded file is empty or missing.');
|
|
}
|
|
|
|
error_log(sprintf(
|
|
'[DataSource] Upload requested: name=%s size=%s mime=%s rules=%s',
|
|
$file['name'] ?? '',
|
|
$file['size'] ?? 'unknown',
|
|
$file['type'] ?? 'unknown',
|
|
$description
|
|
));
|
|
|
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
$mimeType = $finfo->file($file['tmp_name']);
|
|
|
|
if (!in_array($mimeType, $allowedMimeTypes, true)) {
|
|
throw new Exception('Invalid file type. Allowed formats: ' . $description);
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
|
|
if (!in_array($extension, array_map('strtolower', $allowedExtensions), true)) {
|
|
throw new Exception('Invalid file extension. Allowed formats: ' . $description);
|
|
}
|
|
|
|
$originalStem = pathinfo($file['name'], PATHINFO_FILENAME);
|
|
$slug = $this->slugifyFilename($originalStem);
|
|
$uniqueFilename = 'datasource_' . uniqid();
|
|
if (!empty($slug)) {
|
|
$uniqueFilename .= '_' . $slug;
|
|
}
|
|
$uniqueFilename .= '.' . $extension;
|
|
$destination = $this->uploadDir . $uniqueFilename;
|
|
|
|
if (!move_uploaded_file($file['tmp_name'], $destination)) {
|
|
$moved = false;
|
|
if (PHP_SAPI === 'cli' || defined('STDIN')) {
|
|
$moved = rename($file['tmp_name'], $destination);
|
|
}
|
|
if (!$moved) {
|
|
error_log(sprintf('[DataSource] Failed to move uploaded file into %s (tmp=%s)', $destination, $file['tmp_name']));
|
|
throw new Exception('Failed to move uploaded file.');
|
|
}
|
|
}
|
|
|
|
error_log(sprintf('[DataSource] File stored as %s', $uniqueFilename));
|
|
return $uniqueFilename;
|
|
}
|
|
private function resolvePrimaryFileRules(?string $typeName): array {
|
|
if (!$typeName) {
|
|
return self::DEFAULT_FILE_RULES;
|
|
}
|
|
$key = strtolower(trim($typeName));
|
|
if (isset(self::TYPE_SPECIFIC_RULES[$key])) {
|
|
return self::TYPE_SPECIFIC_RULES[$key];
|
|
}
|
|
foreach (self::TYPE_SPECIFIC_RULES as $alias => $rules) {
|
|
if (str_contains($alias, $key) || str_contains($key, $alias)) {
|
|
return $rules;
|
|
}
|
|
}
|
|
// fallback to defaults but include description referencing requested type
|
|
return self::DEFAULT_FILE_RULES;
|
|
}
|
|
|
|
public function getPrimaryFileRulesForType(?string $typeName): array {
|
|
return $this->resolvePrimaryFileRules($typeName);
|
|
}
|
|
|
|
private function slugifyFilename(?string $value): string {
|
|
if (!$value) {
|
|
return '';
|
|
}
|
|
$value = strtolower($value);
|
|
// Replace non-alphanumeric characters with hyphens.
|
|
$value = preg_replace('/[^a-z0-9]+/i', '-', $value);
|
|
$value = trim($value ?? '', '-');
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
// Limit slug length to avoid overlong filenames.
|
|
return substr($value, 0, 50);
|
|
}
|
|
|
|
// --- Data Source Permission Management (dsps_tbl_datasource_permission) ---
|
|
|
|
/**
|
|
* Requests permission for a data source for a specific user.
|
|
*
|
|
* @param int $fkdspsds_id The ID of the data source.
|
|
* @param int $fkisp_id_of The person ID of the user requesting permission.
|
|
* @param string $permission The type of permission ('Read', 'Download', 'Analyze').
|
|
* @param string|null $notes Any notes from the requester.
|
|
* @param int $reg_by The user ID who initiated the request (usually the data user themselves).
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs or a pending request already exists.
|
|
*/
|
|
public function requestDataSourcePermission(
|
|
int $fkdspsds_id,
|
|
int $fkisp_id_of,
|
|
string $permission,
|
|
?string $notes,
|
|
int $reg_by,
|
|
?string $proofPath = null
|
|
): bool {
|
|
// Check if a pending request already exists for this user and data source
|
|
$checkSql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission
|
|
WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of AND dspsdsp_status = 'Pending'";
|
|
$stmtCheck = $this->pdo->prepare($checkSql);
|
|
$stmtCheck->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
|
|
$stmtCheck->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
|
|
$stmtCheck->execute();
|
|
if ($stmtCheck->fetchColumn() > 0) {
|
|
throw new Exception("You already have a pending permission request for this data source.");
|
|
}
|
|
|
|
// Detect any existing permission (approved/rejected/etc.) to avoid duplicate key violations
|
|
$existingSql = "SELECT dspsdsp_status FROM dsps_tbl_datasource_permission
|
|
WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of LIMIT 1";
|
|
$stmtExisting = $this->pdo->prepare($existingSql);
|
|
$stmtExisting->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
|
|
$stmtExisting->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
|
|
$stmtExisting->execute();
|
|
if ($row = $stmtExisting->fetch(PDO::FETCH_ASSOC)) {
|
|
$statusLabel = $row['dspsdsp_status'] ?? 'processed';
|
|
throw new Exception("This request was already {$statusLabel}. Please check My Permissions.");
|
|
}
|
|
|
|
$hasProofColumn = $this->ensurePermissionProofColumn();
|
|
$columns = [
|
|
'fkdspsds_id',
|
|
'fkisp_id_of',
|
|
'dspsdsp_permission',
|
|
'dspsdsp_notes'
|
|
];
|
|
$placeholders = [
|
|
':fkdspsds_id',
|
|
':fkisp_id_of',
|
|
':permission',
|
|
':notes'
|
|
];
|
|
|
|
if ($hasProofColumn) {
|
|
$columns[] = 'dspsdsp_proof_path';
|
|
$placeholders[] = ':proof_path';
|
|
}
|
|
|
|
$columns[] = 'dspsdsp_status';
|
|
$columns[] = 'dspsdsp_reg_by';
|
|
$placeholders[] = ':status';
|
|
$placeholders[] = ':reg_by';
|
|
|
|
$sql = 'INSERT INTO dsps_tbl_datasource_permission (' . implode(', ', $columns) . ')
|
|
VALUES (' . implode(', ', $placeholders) . ')';
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
|
|
$stmt->bindParam(':permission', $permission);
|
|
if ($notes === null) {
|
|
$stmt->bindValue(':notes', null, PDO::PARAM_NULL);
|
|
} else {
|
|
$stmt->bindValue(':notes', $notes, PDO::PARAM_STR);
|
|
}
|
|
|
|
if ($hasProofColumn) {
|
|
if ($proofPath === null) {
|
|
$stmt->bindValue(':proof_path', null, PDO::PARAM_NULL);
|
|
} else {
|
|
$stmt->bindValue(':proof_path', $proofPath, PDO::PARAM_STR);
|
|
}
|
|
}
|
|
|
|
$stmt->bindValue(':status', 'Pending', PDO::PARAM_STR);
|
|
$stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error requesting data source permission: " . $e->getMessage());
|
|
throw new Exception("Could not submit permission request. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves permission requests for a specific Data Owner.
|
|
*
|
|
* @param int $owner_person_id The person ID of the Data Owner.
|
|
* @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked').
|
|
* @return array An array of permission request data.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getPermissionRequestsForOwner(int $ownerPersonId, ?string $status = null): array {
|
|
$hasProofColumn = $this->ensurePermissionProofColumn();
|
|
$proofSelect = $hasProofColumn ? 't1.dspsdsp_proof_path' : 'NULL';
|
|
$proofSelect .= ' AS dspsdsp_proof_path';
|
|
|
|
$sql = "SELECT
|
|
t1.pkdspsdsp_id,
|
|
t2.dspsds_title_en,
|
|
t3.isp_firstname_en,
|
|
t3.isp_lastname_en,
|
|
t1.dspsdsp_permission,
|
|
t1.dspsdsp_reg_datetime,
|
|
t1.dspsdsp_notes,
|
|
$proofSelect,
|
|
t1.dspsdsp_status
|
|
FROM dsps_tbl_datasource_permission AS t1
|
|
JOIN dsps_tbl_datasource AS t2 ON t1.fkdspsds_id = t2.pkdspsds_id
|
|
JOIN ist_tbl_people AS t3 ON t1.fkisp_id_of = t3.pkisp_id
|
|
WHERE t2.fkisp_id_of = :ownerPersonId";
|
|
|
|
if ($status !== null) {
|
|
$sql .= " AND t1.dspsdsp_status = :status";
|
|
}
|
|
|
|
$sql .= " ORDER BY t1.dspsdsp_reg_datetime DESC";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT);
|
|
if ($status !== null) {
|
|
$stmt->bindParam(':status', $status);
|
|
}
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Database error in getPermissionRequestsForOwner: " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves a single permission request by its ID.
|
|
* Includes data source owner's person ID for authorization checks.
|
|
*
|
|
* @param int $permissionId The ID of the permission request.
|
|
* @return array|false The permission data as an associative array, or false if not found.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getPermissionRequestById(int $permissionId) {
|
|
$sql = "SELECT dsp.*, ds.fkisp_id_of AS datasource_owner_person_id
|
|
FROM dsps_tbl_datasource_permission dsp
|
|
JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id
|
|
WHERE dsp.pkdspsdsp_id = :permission_id";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':permission_id', $permissionId, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching permission request by ID ($permissionId): " . $e->getMessage());
|
|
throw new Exception("Could not retrieve permission request details. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all permission requests for DAC Staff, optionally filtered by status and search query.
|
|
*
|
|
* @param string|null $status_filter Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked').
|
|
* @param string|null $search_query Optional: Search by data source title, requester name, or owner name.
|
|
* @return array An array of all permission request data.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getAllPermissionRequests(?string $status_filter = null, ?string $search_query = null): array {
|
|
$sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_filename,
|
|
requester_p.isp_firstname_en AS requester_firstname, requester_p.isp_lastname_en AS requester_lastname,
|
|
owner_p.isp_firstname_en AS owner_firstname, owner_p.isp_lastname_en AS owner_lastname
|
|
FROM dsps_tbl_datasource_permission dsp
|
|
JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id
|
|
JOIN ist_tbl_people requester_p ON dsp.fkisp_id_of = requester_p.pkisp_id
|
|
JOIN ist_tbl_people owner_p ON ds.fkisp_id_of = owner_p.pkisp_id";
|
|
$conditions = [];
|
|
$params = [];
|
|
|
|
if ($status_filter) {
|
|
$conditions[] = "dsp.dspsdsp_status = :status_filter";
|
|
$params[':status_filter'] = $status_filter;
|
|
}
|
|
|
|
if ($search_query) {
|
|
$search_term = '%' . $search_query . '%';
|
|
$conditions[] = "(ds.dspsds_title_en LIKE :search_query OR
|
|
requester_p.isp_firstname_en LIKE :search_query OR
|
|
requester_p.isp_lastname_en LIKE :search_query OR
|
|
owner_p.isp_firstname_en LIKE :search_query OR
|
|
owner_p.isp_lastname_en LIKE :search_query)";
|
|
$params[':search_query'] = $search_term;
|
|
}
|
|
|
|
if (!empty($conditions)) {
|
|
$sql .= " WHERE " . implode(" AND ", $conditions);
|
|
}
|
|
|
|
$sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($params as $key => &$val) {
|
|
$stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
|
}
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching all permission requests: " . $e->getMessage());
|
|
throw new Exception("Could not retrieve all permission requests. Please try again later.");
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Gets the count of pending permission requests.
|
|
*
|
|
* @return int The count of pending permission requests.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getPendingPermissionRequestsCount(): int {
|
|
$sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission WHERE dspsdsp_status = 'Pending'";
|
|
try {
|
|
$stmt = $this->pdo->query($sql);
|
|
return $stmt->fetchColumn();
|
|
} catch (PDOException $e) {
|
|
error_log("Error getting pending permission requests count: " . $e->getMessage());
|
|
throw new Exception("Could not retrieve pending permission count. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aggregates total usage counts per data source owner (person id).
|
|
*
|
|
* @param int $limit Maximum number of owners to return.
|
|
* @return array<int, array{owner_person_id:int, owner_name:string, usage_count:int}>
|
|
*/
|
|
public function getUsageByOwner(int $limit = 8): array {
|
|
$sql = "SELECT ds.fkisp_id_of AS owner_person_id,
|
|
CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name,
|
|
COUNT(dsu.pkdspsdspused_id) AS usage_count
|
|
FROM dsps_tbl_datasource_used dsu
|
|
JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
|
|
JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id
|
|
GROUP BY ds.fkisp_id_of, owner_name
|
|
ORDER BY usage_count DESC
|
|
LIMIT :limit";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching usage by owner: " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the status of a data source permission request.
|
|
*
|
|
* @param int $permission_id The ID of the permission request.
|
|
* @param string $new_status The new status ('Approved', 'Rejected', 'Revoked').
|
|
* @param string|null $notes Optional notes for the status change.
|
|
* @param int $mod_by The user ID of the Data Owner or DAC Staff who updated the status.
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function updatePermissionStatus(int $permissionId, string $newStatus, int $modifyingUserId, string $notes = ''): bool {
|
|
$sql = "UPDATE dsps_tbl_datasource_permission
|
|
SET
|
|
dspsdsp_status = :newStatus,
|
|
dspsdsp_mod_datetime = NOW(),
|
|
dspsdsp_reg_by = :modifyingUserId,
|
|
dspsdsp_notes = :notes
|
|
WHERE pkdspsdsp_id = :permissionId";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':newStatus', $newStatus);
|
|
$stmt->bindParam(':modifyingUserId', $modifyingUserId, PDO::PARAM_INT);
|
|
$stmt->bindParam(':notes', $notes);
|
|
$stmt->bindParam(':permissionId', $permissionId, PDO::PARAM_INT);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error updating permission status: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a user has a specific permission for a data source.
|
|
*
|
|
* @param int $user_person_id The person ID of the user.
|
|
* @param int $data_source_id The ID of the data source.
|
|
* @param string $permission_type The type of permission to check ('Read', 'Download', 'Analyze').
|
|
* @return bool True if the user has the permission, false otherwise.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function hasPermission(int $user_person_id, int $data_source_id, string $permission_type): bool {
|
|
$sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission
|
|
WHERE fkisp_id_of = :user_person_id
|
|
AND fkdspsds_id = :data_source_id
|
|
AND dspsdsp_permission = :permission_type
|
|
AND dspsdsp_status = 'Approved'";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':data_source_id', $data_source_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':permission_type', $permission_type);
|
|
$stmt->execute();
|
|
return $stmt->fetchColumn() > 0;
|
|
} catch (PDOException $e) {
|
|
error_log("Error checking permission for user ($user_person_id) on data source ($data_source_id): " . $e->getMessage());
|
|
throw new Exception("Could not verify permission. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns usage counts grouped by consuming user for data sources owned by a person.
|
|
*
|
|
* @param int $ownerPersonId
|
|
* @param int $limit
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getUsageByUserForOwner(int $ownerPersonId, int $limit = 10): array {
|
|
$sql = "SELECT u.isu_name AS username,
|
|
COUNT(dsu.pkdspsdspused_id) AS usage_count
|
|
FROM dsps_tbl_datasource_used dsu
|
|
JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
|
|
JOIN ist_tbl_users u ON dsu.dspsdspused_reg_by = u.pkisu_id
|
|
WHERE ds.fkisp_id_of = :ownerPersonId
|
|
GROUP BY u.pkisu_id, u.isu_name
|
|
ORDER BY usage_count DESC
|
|
LIMIT :limit";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT);
|
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching usage by user for owner ($ownerPersonId): " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves usage counts for a specific user grouped by data source.
|
|
*
|
|
* @param int $userPersonId
|
|
* @param int $limit
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getUsageByDataSourceForUser(int $userPersonId, int $limit = 8): array {
|
|
$sql = "SELECT ds.pkdspsds_id,
|
|
ds.dspsds_title_en,
|
|
COUNT(dsu.pkdspsdspused_id) AS usage_count
|
|
FROM dsps_tbl_datasource_used dsu
|
|
JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
|
|
WHERE dsu.dspsdspused_reg_by = :userPersonId
|
|
GROUP BY ds.pkdspsds_id, ds.dspsds_title_en
|
|
ORDER BY usage_count DESC
|
|
LIMIT :limit";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':userPersonId', $userPersonId, PDO::PARAM_INT);
|
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching usage by data source for user ($userPersonId): " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all data sources with owner, type, and category details.
|
|
*
|
|
* @param string|null $search Optional search across title, owner, or type.
|
|
* @param string|null $status Optional status filter.
|
|
* @return array
|
|
*/
|
|
public function getAllDataSourcesDetailed(?string $search = null, ?string $status = null): array {
|
|
$sql = "SELECT ds.*,
|
|
dspstds.dspstds_name_en AS data_type_name,
|
|
dspscate.dspscate_title_en AS category_name,
|
|
CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name
|
|
FROM dsps_tbl_datasource ds
|
|
JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
|
|
JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
|
|
JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id";
|
|
|
|
$conditions = [];
|
|
$params = [];
|
|
|
|
if ($status !== null && $status !== '') {
|
|
$conditions[] = "ds.dspsds_status = :status";
|
|
$params[':status'] = $status;
|
|
}
|
|
|
|
if ($search !== null && $search !== '') {
|
|
$conditions[] = "(ds.dspsds_title_en LIKE :search OR p.isp_firstname_en LIKE :search OR p.isp_lastname_en LIKE :search OR dspstds.dspstds_name_en LIKE :search)";
|
|
$params[':search'] = '%' . $search . '%';
|
|
}
|
|
|
|
if (!empty($conditions)) {
|
|
$sql .= " WHERE " . implode(' AND ', $conditions);
|
|
}
|
|
|
|
$sql .= " ORDER BY ds.dspsds_reg_datetime DESC";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($params as $key => $value) {
|
|
$stmt->bindValue($key, $value);
|
|
}
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching all data sources: " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the status of a data source.
|
|
*
|
|
* @param int $datasourceId
|
|
* @param string $newStatus
|
|
* @param int $modifyingUserId
|
|
* @return bool
|
|
*/
|
|
public function updateDataSourceStatus(int $datasourceId, string $newStatus, int $modifyingUserId): bool {
|
|
$allowed = ['Active', 'Inactive', 'Pending Review', 'Published'];
|
|
if (!in_array($newStatus, $allowed, true)) {
|
|
throw new InvalidArgumentException("Invalid data source status value.");
|
|
}
|
|
|
|
$sql = "UPDATE dsps_tbl_datasource
|
|
SET dspsds_status = :status,
|
|
dspsds_mod_datetime = CURRENT_TIMESTAMP,
|
|
dspsds_reg_by = :modBy
|
|
WHERE pkdspsds_id = :id";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':status', $newStatus);
|
|
$stmt->bindValue(':modBy', $modifyingUserId, PDO::PARAM_INT);
|
|
$stmt->bindValue(':id', $datasourceId, PDO::PARAM_INT);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error updating data source status (ID: $datasourceId): " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all permissions for a specific user.
|
|
*
|
|
* @param int $user_person_id The person ID of the user.
|
|
* @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked').
|
|
* @return array An array of permission data.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getUserPermissions(int $user_person_id, ?string $status = null): array {
|
|
$sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_description, ds.dspsds_filename,
|
|
ds.dspsds_filename1, ds.dspsds_filename2, ds.dspsds_filename3,
|
|
dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name
|
|
FROM dsps_tbl_datasource_permission dsp
|
|
JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id
|
|
JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
|
|
JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
|
|
WHERE dsp.fkisp_id_of = :user_person_id";
|
|
$params = [':user_person_id' => $user_person_id];
|
|
|
|
if ($status) {
|
|
$sql .= " AND dsp.dspsdsp_status = :status";
|
|
$params[':status'] = $status;
|
|
}
|
|
|
|
$sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($params as $key => &$val) {
|
|
$stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
|
}
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching user permissions ($user_person_id): " . $e->getMessage());
|
|
throw new Exception("Could not retrieve your permissions. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all approved data sources a user is permitted to analyse/download.
|
|
*
|
|
* @param int $userPersonId The person ID tied to the current session.
|
|
* @return array
|
|
* @throws Exception
|
|
*/
|
|
public function getApprovedDataSourcesForUser(int $userPersonId): array {
|
|
return $this->getUserPermissions($userPersonId, 'Approved');
|
|
}
|
|
|
|
/**
|
|
* Synchronises the approved data sources for a user into a dedicated Jupyter workspace directory.
|
|
* Files are symlinked when possible to avoid duplication; if symlinks are not permitted, files are copied.
|
|
*
|
|
* @param int $userPersonId The person ID of the user.
|
|
* @param string $workspaceRoot Absolute path to the shared Jupyter workspace root.
|
|
* @return array{synced: array<int, array<string, mixed>>, missing: array<int, array<string, mixed>>, workspace_dir: string}
|
|
* @throws Exception When the workspace cannot be prepared.
|
|
*/
|
|
public function prepareJupyterWorkspace(int $userPersonId, string $workspaceRoot): array {
|
|
if (!is_dir($workspaceRoot) && !mkdir($workspaceRoot, 0775, true) && !is_dir($workspaceRoot)) {
|
|
throw new RuntimeException("Unable to create Jupyter workspace root at {$workspaceRoot}");
|
|
}
|
|
|
|
$userFolderName = 'user_' . $userPersonId;
|
|
$userWorkspaceDir = rtrim($workspaceRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $userFolderName;
|
|
|
|
if (!is_dir($userWorkspaceDir) && !mkdir($userWorkspaceDir, 0775, true) && !is_dir($userWorkspaceDir)) {
|
|
throw new RuntimeException("Unable to create user workspace directory at {$userWorkspaceDir}");
|
|
}
|
|
|
|
$this->purgeSyncedArtifacts($userWorkspaceDir);
|
|
|
|
$approvedSources = $this->getApprovedDataSourcesForUser($userPersonId);
|
|
$synced = [];
|
|
$missing = [];
|
|
|
|
foreach ($approvedSources as $index => $permission) {
|
|
$sourceId = (int)($permission['fkdspsds_id'] ?? 0);
|
|
$title = $permission['dspsds_title_en'] ?? ('Data Source ' . $sourceId);
|
|
$fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3'];
|
|
$fileQueue = [];
|
|
|
|
foreach ($fileColumns as $column) {
|
|
$candidate = trim((string)($permission[$column] ?? ''));
|
|
if ($candidate !== '' && !in_array($candidate, $fileQueue, true)) {
|
|
$fileQueue[] = $candidate;
|
|
}
|
|
}
|
|
|
|
if (empty($fileQueue)) {
|
|
$missing[] = [
|
|
'datasource_id' => $sourceId,
|
|
'title' => $title,
|
|
'reason' => 'No files associated with this data source.',
|
|
];
|
|
continue;
|
|
}
|
|
|
|
foreach ($fileQueue as $fileIndex => $filename) {
|
|
$sourcePath = $this->uploadDir . $filename;
|
|
if (!is_file($sourcePath)) {
|
|
$missing[] = [
|
|
'datasource_id' => $sourceId,
|
|
'title' => $title,
|
|
'reason' => 'File not found on disk: ' . $filename,
|
|
];
|
|
continue;
|
|
}
|
|
|
|
$extension = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
|
|
|
|
if ($extension === 'ipynb') {
|
|
$missing[] = [
|
|
'datasource_id' => $sourceId,
|
|
'title' => $title,
|
|
'reason' => 'Notebook files (.ipynb) are personal workspaces and are not shared via sync.',
|
|
];
|
|
continue;
|
|
}
|
|
|
|
$safeTitle = $this->sanitizeFileName($title);
|
|
$targetBase = sprintf('%03d_%s', $index + 1, $safeTitle ?: ('datasource_' . $sourceId));
|
|
if ($fileIndex > 0) {
|
|
$targetBase .= '_file' . ($fileIndex + 1);
|
|
}
|
|
$targetName = $extension ? "{$targetBase}.{$extension}" : $targetBase;
|
|
$targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $targetName;
|
|
|
|
if (file_exists($targetPath) || is_link($targetPath)) {
|
|
unlink($targetPath);
|
|
}
|
|
|
|
$linked = @symlink($sourcePath, $targetPath);
|
|
if (!$linked) {
|
|
$linked = @copy($sourcePath, $targetPath);
|
|
}
|
|
|
|
if ($linked) {
|
|
$synced[] = [
|
|
'datasource_id' => $sourceId,
|
|
'title' => $title,
|
|
'source_filename' => $filename,
|
|
'relative_path' => $userFolderName . '/' . $targetName,
|
|
'absolute_path' => $targetPath,
|
|
'data_type' => $permission['data_type_name'] ?? null,
|
|
'category' => $permission['category_name'] ?? null,
|
|
];
|
|
} else {
|
|
$missing[] = [
|
|
'datasource_id' => $sourceId,
|
|
'title' => $title,
|
|
'reason' => 'Unable to sync file into workspace: ' . $filename,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$readmePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'README.txt';
|
|
$readmeBody = "Approved data sources for R in JupyterHub are synced to this folder.\n"
|
|
. "Each file is a symlink (or copy) of the original upload.\n"
|
|
. "Only data sources with approved permissions for your account appear here.\n"
|
|
. "Synced on: " . date('c') . "\n";
|
|
file_put_contents($readmePath, $readmeBody);
|
|
|
|
$manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json';
|
|
file_put_contents($manifestPath, json_encode([
|
|
'generated_at' => date('c'),
|
|
'synced' => $synced,
|
|
'missing' => $missing,
|
|
], JSON_PRETTY_PRINT));
|
|
|
|
return [
|
|
'synced' => $synced,
|
|
'missing' => $missing,
|
|
'workspace_dir' => $userWorkspaceDir,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Removes previously synced artefacts without touching user-authored notebooks.
|
|
*
|
|
* @param string $userWorkspaceDir
|
|
* @return void
|
|
*/
|
|
private function purgeSyncedArtifacts(string $userWorkspaceDir): void {
|
|
$manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json';
|
|
|
|
if (is_readable($manifestPath)) {
|
|
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
|
if (isset($manifest['synced']) && is_array($manifest['synced'])) {
|
|
foreach ($manifest['synced'] as $syncedItem) {
|
|
if (empty($syncedItem['relative_path'])) {
|
|
continue;
|
|
}
|
|
|
|
$basename = basename($syncedItem['relative_path']);
|
|
if ($basename === '' || $basename === '.' || $basename === '..') {
|
|
continue;
|
|
}
|
|
|
|
$targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $basename;
|
|
if (is_file($targetPath) || is_link($targetPath)) {
|
|
@unlink($targetPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (['README.txt', 'manifest.json'] as $generatedFile) {
|
|
$filePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $generatedFile;
|
|
if (is_file($filePath)) {
|
|
@unlink($filePath);
|
|
}
|
|
}
|
|
|
|
// Legacy clean-up: remove any auto-synced notebooks that may still exist from older runs.
|
|
// The sync process previously generated notebooks using a ###_ prefix; strip those out so
|
|
// personal notebooks authored by the user (custom names) remain untouched.
|
|
$iterator = new FilesystemIterator($userWorkspaceDir, FilesystemIterator::SKIP_DOTS);
|
|
foreach ($iterator as $item) {
|
|
if ($item->isFile() && preg_match('/^\d{3}_.+\.ipynb$/i', $item->getFilename())) {
|
|
@unlink($item->getPathname());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes all files and folders within a directory without deleting the directory itself.
|
|
*
|
|
* @param string $directory
|
|
* @return void
|
|
*/
|
|
private function clearDirectory(string $directory): void {
|
|
if (!is_dir($directory)) {
|
|
return;
|
|
}
|
|
|
|
$iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
|
|
foreach ($iterator as $item) {
|
|
if ($item->isDir()) {
|
|
$this->deleteDirectory($item->getPathname());
|
|
} else {
|
|
unlink($item->getPathname());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively deletes a directory.
|
|
*
|
|
* @param string $path
|
|
* @return void
|
|
*/
|
|
private function deleteDirectory(string $path): void {
|
|
if (!is_dir($path)) {
|
|
return;
|
|
}
|
|
|
|
$items = new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS);
|
|
foreach ($items as $item) {
|
|
if ($item->isDir()) {
|
|
$this->deleteDirectory($item->getPathname());
|
|
} else {
|
|
unlink($item->getPathname());
|
|
}
|
|
}
|
|
rmdir($path);
|
|
}
|
|
|
|
/**
|
|
* Sanitises a string for safe use as a file name.
|
|
*
|
|
* @param string $name
|
|
* @return string
|
|
*/
|
|
private function sanitizeFileName(string $name): string {
|
|
$sanitised = preg_replace('/[^a-zA-Z0-9-_]+/', '_', $name);
|
|
return trim($sanitised, '_');
|
|
}
|
|
|
|
// --- Usage Logging (dsps_tbl_anonymous, dsps_tbl_datasource_used) ---
|
|
|
|
/**
|
|
* Logs an anonymous view of a data source introduction.
|
|
*
|
|
* @param int $fkdspsds_id The ID of the data source viewed.
|
|
* @param string|null $client_ip The IP address of the viewer.
|
|
* @param string $action The action performed (e.g., 'View Introduction').
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function logAnonymousView(int $fkdspsds_id, ?string $client_ip, string $action): bool {
|
|
$sql = "INSERT INTO dsps_tbl_anonymous (fkdspsds_id, dspsano_client_ip, dspsano_action)
|
|
VALUES (:fkdspsds_id, :client_ip, :action)";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':client_ip', $client_ip);
|
|
$stmt->bindParam(':action', $action);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error logging anonymous view for data source ($fkdspsds_id): " . $e->getMessage());
|
|
throw new Exception("Could not log anonymous view.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logs a registered user's usage of a data source.
|
|
*
|
|
* @param int $fkdspsdsused_id The ID of the data source used.
|
|
* @param int $fkisp_id_of The person ID of the user who used it.
|
|
* @param string $action The action performed (e.g., 'Downloaded', 'Accessed API', 'Ran Analysis').
|
|
* @param int $reg_by The user ID who performed the action.
|
|
* @return bool True on success.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function logDataSourceUsage(int $fkdspsdsused_id, int $fkisp_id_of, string $action, int $reg_by): bool {
|
|
$sql = "INSERT INTO dsps_tbl_datasource_used (fkdspsdsused_id, fkisp_id_of, dspsdspused_action, dspsdspused_reg_by)
|
|
VALUES (:fkdspsdsused_id, :fkisp_id_of, :action, :reg_by)";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':fkdspsdsused_id', $fkdspsdsused_id, PDO::PARAM_INT);
|
|
$stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
|
|
$stmt->bindParam(':action', $action);
|
|
$stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT);
|
|
return $stmt->execute();
|
|
} catch (PDOException $e) {
|
|
error_log("Error logging data source usage for user ($fkisp_id_of) on data source ($fkdspsdsused_id): " . $e->getMessage());
|
|
throw new Exception("Could not log data source usage.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves usage logs for a specific data source.
|
|
*
|
|
* @param int $data_source_id The ID of the data source.
|
|
* @param string|null $action Optional: Filter by action.
|
|
* @return array An array of usage log data.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getDataSourceUsageLogs(int $data_source_id, ?string $action = null): array {
|
|
$sql = "SELECT dsu.*, p.isp_firstname_en, p.isp_lastname_en
|
|
FROM dsps_tbl_datasource_used dsu
|
|
JOIN ist_tbl_people p ON dsu.fkisp_id_of = p.pkisp_id
|
|
WHERE dsu.fkdspsdsused_id = :data_source_id";
|
|
$params = [':data_source_id' => $data_source_id];
|
|
|
|
if ($action) {
|
|
$sql .= " AND dsu.dspsdspused_action = :action";
|
|
$params[':action'] = $action;
|
|
}
|
|
|
|
$sql .= " ORDER BY dsu.dspsdspused_reg_datetime DESC";
|
|
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($params as $key => &$val) {
|
|
$stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
|
}
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching data source usage logs for ($data_source_id): " . $e->getMessage());
|
|
throw new Exception("Could not retrieve usage logs. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves a user's download history.
|
|
*
|
|
* @param int $user_person_id The person ID of the user.
|
|
* @return array An array of downloaded data sources.
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getUserDownloads(int $user_person_id): array {
|
|
$sql = "SELECT dsu.*, ds.dspsds_title_en, ds.dspsds_filename,
|
|
dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name
|
|
FROM dsps_tbl_datasource_used dsu
|
|
JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
|
|
JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
|
|
JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
|
|
WHERE dsu.fkisp_id_of = :user_person_id AND dsu.dspsdspused_action = 'Downloaded'
|
|
ORDER BY dsu.dspsdspused_reg_datetime DESC";
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching user downloads for ($user_person_id): " . $e->getMessage());
|
|
throw new Exception("Could not retrieve your downloads. Please try again later.");
|
|
}
|
|
}
|
|
|
|
// --- Classification Methods (Moved from Classifications.php for simplicity, or keep separate) ---
|
|
// Assuming these methods are here based on previous discussions.
|
|
// If you have a separate Classifications.php, ensure these are in that file.
|
|
|
|
/**
|
|
* Retrieves all data types.
|
|
* @return array
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getAllDataTypes(): array {
|
|
$sql = "SELECT * FROM dsps_tbl_typedatasource ORDER BY dspstds_name_en ASC";
|
|
try {
|
|
$stmt = $this->pdo->query($sql);
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching all data types: " . $e->getMessage());
|
|
throw new Exception("Could not retrieve data types. Please try again later.");
|
|
}
|
|
}
|
|
public function getDataTypeById(int $typeId): ?array {
|
|
$sql = "SELECT * FROM dsps_tbl_typedatasource WHERE pkdspstds_id = :type_id";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':type_id', $typeId, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
return $result ?: null;
|
|
}
|
|
|
|
/**
|
|
* Retrieves all data categories.
|
|
* @return array
|
|
* @throws Exception If a database error occurs.
|
|
*/
|
|
public function getAllCategories(): array {
|
|
$sql = "SELECT * FROM dsps_tbl_dspscategory ORDER BY dspscate_title_en ASC";
|
|
try {
|
|
$stmt = $this->pdo->query($sql);
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log("Error fetching all categories: " . $e->getMessage());
|
|
throw new Exception("Could not retrieve categories. Please try again later.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a list of data sources, optionally filtered by category and search query.
|
|
* @param int|null $categoryId The ID of the category to filter by.
|
|
* @param string|null $searchQuery The search query to filter by title or description.
|
|
* @return array An array of data source records.
|
|
*/
|
|
public function getFilteredDataSources($categoryId = null, $searchQuery = null)
|
|
{
|
|
try {
|
|
// Use LEFT JOIN to get data from related tables.
|
|
// All table and column names are now verified against the provided SQL dump.
|
|
$sql = "
|
|
SELECT
|
|
ds.*,
|
|
COALESCE(cat.dspscate_title_en, 'Not specified') AS category_name,
|
|
COALESCE(dt.dspstds_name_en, 'Not specified') AS data_type_name,
|
|
COALESCE(p.isp_firstname_en, 'Not specified') AS isp_firstname_en,
|
|
COALESCE(p.isp_lastname_en, 'Not specified') AS isp_lastname_en
|
|
FROM dsps_tbl_datasource AS ds
|
|
LEFT JOIN dsps_tbl_dspscategory AS cat ON ds.fkdspscate_id = cat.pkdspscate_id
|
|
LEFT JOIN dsps_tbl_typedatasource AS dt ON ds.fkdspstds_id = dt.pkdspstds_id
|
|
LEFT JOIN ist_tbl_people AS p ON ds.fkisp_id_of = p.pkisp_id
|
|
WHERE ds.dspsds_status = 'Active'
|
|
";
|
|
|
|
$params = [];
|
|
$whereClauses = [];
|
|
|
|
if ($categoryId) {
|
|
$whereClauses[] = "ds.fkdspscate_id = :category_id";
|
|
$params[':category_id'] = $categoryId;
|
|
}
|
|
|
|
if ($searchQuery) {
|
|
$search = "%" . $searchQuery . "%";
|
|
$whereClauses[] = "(ds.dspsds_title_en LIKE :search OR ds.dspsds_description LIKE :search)";
|
|
$params[':search'] = $search;
|
|
}
|
|
|
|
if (!empty($whereClauses)) {
|
|
$sql .= " AND " . implode(" AND ", $whereClauses);
|
|
}
|
|
|
|
// Order by title for a consistent display
|
|
$sql .= " ORDER BY ds.dspsds_title_en ASC";
|
|
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
} catch (PDOException $e) {
|
|
// Log the error and return an empty array to prevent further issues
|
|
error_log("Database error in getFilteredDataSources: " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function tableColumnExists(string $table, string $column): bool {
|
|
$cacheKey = $table . '.' . $column;
|
|
if (array_key_exists($cacheKey, $this->columnExistenceCache)) {
|
|
return $this->columnExistenceCache[$cacheKey];
|
|
}
|
|
|
|
if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
|
|
return false;
|
|
}
|
|
|
|
$sql = sprintf('SHOW COLUMNS FROM `%s` LIKE :column', $table);
|
|
try {
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindParam(':column', $column, PDO::PARAM_STR);
|
|
$stmt->execute();
|
|
$exists = (bool) $stmt->fetch(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
error_log('Error checking column existence: ' . $e->getMessage());
|
|
// Assume column exists if we cannot verify to avoid silently skipping new features.
|
|
$exists = true;
|
|
}
|
|
|
|
$this->columnExistenceCache[$cacheKey] = $exists;
|
|
return $exists;
|
|
}
|
|
|
|
private function ensurePermissionProofColumn(): bool {
|
|
$table = 'dsps_tbl_datasource_permission';
|
|
$column = 'dspsdsp_proof_path';
|
|
$cacheKey = $table . '.' . $column;
|
|
|
|
if ($this->tableColumnExists($table, $column)) {
|
|
return true;
|
|
}
|
|
|
|
$alterSql = "ALTER TABLE `{$table}` ADD COLUMN `{$column}` VARCHAR(255) DEFAULT NULL AFTER dspsdsp_notes";
|
|
try {
|
|
$this->pdo->exec($alterSql);
|
|
$this->columnExistenceCache[$cacheKey] = true;
|
|
return true;
|
|
} catch (PDOException $e) {
|
|
error_log('Failed to add proof column: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
}
|