Add canonical user access profile table
Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups. Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, or deleted. Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
use App\Models\LoginActivityModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\UserAccessProfileModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\IpAttemptModel;
|
||||
use App\Models\PasswordResetModel;
|
||||
@@ -212,6 +213,7 @@ class AuthController extends BaseController
|
||||
|
||||
// Fetch roles
|
||||
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
||||
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||
|
||||
// Build roles map (object with keys per example)
|
||||
$rolesMap = [];
|
||||
@@ -248,6 +250,10 @@ class AuthController extends BaseController
|
||||
'id' => (int) $user['id'],
|
||||
'name' => $payload['name'],
|
||||
'roles' => (object) $rolesMap,
|
||||
'primary_category' => $accessProfile['primary_category'],
|
||||
'is_admin' => $accessProfile['is_admin'],
|
||||
'is_teacher' => $accessProfile['is_teacher'],
|
||||
'is_parent' => $accessProfile['is_parent'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -274,6 +280,10 @@ class AuthController extends BaseController
|
||||
'type' => session()->get('user_type'),
|
||||
'roles' => $roles,
|
||||
'role' => $activeRole,
|
||||
'primary_category' => session()->get('primary_category'),
|
||||
'is_admin' => (bool) session()->get('is_admin'),
|
||||
'is_teacher' => (bool) session()->get('is_teacher'),
|
||||
'is_parent' => (bool) session()->get('is_parent'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -371,6 +381,7 @@ class AuthController extends BaseController
|
||||
'iat' => $now,
|
||||
'exp' => $exp,
|
||||
];
|
||||
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
|
||||
|
||||
$secret = require_env('JWT_SECRET');
|
||||
$token = jwt_encode($payload, $secret, 'HS256');
|
||||
@@ -384,6 +395,10 @@ class AuthController extends BaseController
|
||||
'name' => $payload['name'],
|
||||
'email' => $userData['email'],
|
||||
'roles' => $payload['roles'],
|
||||
'primary_category' => $accessProfile['primary_category'],
|
||||
'is_admin' => $accessProfile['is_admin'],
|
||||
'is_teacher' => $accessProfile['is_teacher'],
|
||||
'is_parent' => $accessProfile['is_parent'],
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
@@ -477,11 +492,17 @@ class AuthController extends BaseController
|
||||
protected function getUserRoleNames(int $userId): array
|
||||
{
|
||||
$userRoleModel = new UserRoleModel();
|
||||
$rolesRows = $userRoleModel->select('roles.name')
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $userRoleModel->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||
|
||||
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||
$builder->where('user_roles.deleted_at', null);
|
||||
}
|
||||
|
||||
$rolesRows = $builder->get()->getResultArray();
|
||||
|
||||
return array_column($rolesRows, 'name');
|
||||
}
|
||||
@@ -565,11 +586,17 @@ class AuthController extends BaseController
|
||||
private function loginUser($user, ?string $redirectTo = null)
|
||||
{
|
||||
$userRoleModel = new UserRoleModel();
|
||||
$roles = $userRoleModel->select('roles.name')
|
||||
$db = \Config\Database::connect();
|
||||
$rolesBuilder = $userRoleModel->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $user['id'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||
|
||||
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||
$rolesBuilder->where('user_roles.deleted_at', null);
|
||||
}
|
||||
|
||||
$roles = $rolesBuilder->get()->getResultArray();
|
||||
|
||||
if (empty($roles)) {
|
||||
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
||||
@@ -577,6 +604,7 @@ class AuthController extends BaseController
|
||||
}
|
||||
|
||||
$roleNames = array_column($roles, 'name');
|
||||
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||
|
||||
session()->regenerate(true);
|
||||
session()->set([
|
||||
@@ -588,6 +616,10 @@ class AuthController extends BaseController
|
||||
'login_time' => time(),
|
||||
'last_activity' => time(),
|
||||
'roles' => $roleNames,
|
||||
'primary_category' => $accessProfile['primary_category'],
|
||||
'is_admin' => $accessProfile['is_admin'],
|
||||
'is_teacher' => $accessProfile['is_teacher'],
|
||||
'is_parent' => $accessProfile['is_parent'],
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
@@ -612,6 +644,32 @@ class AuthController extends BaseController
|
||||
|
||||
}
|
||||
|
||||
private function accessProfileForUser(int $userId, array $roleNames): array
|
||||
{
|
||||
try {
|
||||
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
|
||||
if ($profile !== null) {
|
||||
return [
|
||||
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
|
||||
'is_admin' => (bool) ($profile['is_admin'] ?? false),
|
||||
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
|
||||
'is_parent' => (bool) ($profile['is_parent'] ?? false),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
|
||||
|
||||
return [
|
||||
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
|
||||
'is_admin' => (bool) $flags['is_admin'],
|
||||
'is_teacher' => (bool) $flags['is_teacher'],
|
||||
'is_parent' => (bool) $flags['is_parent'],
|
||||
];
|
||||
}
|
||||
|
||||
private function applyStylePreferences(int $userId): void
|
||||
{
|
||||
if (!$userId) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class CreateUserAccessProfiles extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->db->tableExists('user_access_profiles')) {
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'user_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
],
|
||||
'primary_category' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'default' => 'guest',
|
||||
],
|
||||
'is_admin' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'is_teacher' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'is_parent' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'role_names' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('user_id', false, true);
|
||||
$this->forge->addKey('primary_category');
|
||||
$this->forge->addKey('is_admin');
|
||||
$this->forge->addKey('is_teacher');
|
||||
$this->forge->addKey('is_parent');
|
||||
$this->forge->createTable('user_access_profiles', true);
|
||||
}
|
||||
|
||||
$this->backfillProfiles();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('user_access_profiles', true);
|
||||
}
|
||||
|
||||
private function backfillProfiles(): void
|
||||
{
|
||||
if (
|
||||
! $this->db->tableExists('users')
|
||||
|| ! $this->db->tableExists('roles')
|
||||
|| ! $this->db->tableExists('user_roles')
|
||||
|| ! $this->db->tableExists('user_access_profiles')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$deletedFilter = $this->db->fieldExists('deleted_at', 'user_roles')
|
||||
? 'AND ur.deleted_at IS NULL'
|
||||
: '';
|
||||
|
||||
$sql = "
|
||||
INSERT INTO user_access_profiles (
|
||||
user_id,
|
||||
primary_category,
|
||||
is_admin,
|
||||
is_teacher,
|
||||
is_parent,
|
||||
role_names,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'admin'
|
||||
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'teacher'
|
||||
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) = 1 THEN 'parent'
|
||||
ELSE 'guest'
|
||||
END AS primary_category,
|
||||
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_admin,
|
||||
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_teacher,
|
||||
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) AS is_parent,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY COALESCE(r.priority, 999), r.name SEPARATOR ', ') AS role_names,
|
||||
? AS created_at,
|
||||
? AS updated_at
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id {$deletedFilter}
|
||||
LEFT JOIN roles r ON r.id = ur.role_id AND COALESCE(r.is_active, 1) = 1
|
||||
GROUP BY u.id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
primary_category = VALUES(primary_category),
|
||||
is_admin = VALUES(is_admin),
|
||||
is_teacher = VALUES(is_teacher),
|
||||
is_parent = VALUES(is_parent),
|
||||
role_names = VALUES(role_names),
|
||||
updated_at = VALUES(updated_at)
|
||||
";
|
||||
|
||||
$this->db->query($sql, [$now, $now]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class UserAccessProfileModel extends Model
|
||||
{
|
||||
public const CATEGORY_ADMIN = 'admin';
|
||||
public const CATEGORY_TEACHER = 'teacher';
|
||||
public const CATEGORY_PARENT = 'parent';
|
||||
public const CATEGORY_GUEST = 'guest';
|
||||
|
||||
private const TEACHER_ROLE_TOKENS = ['teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||
private const NON_ADMIN_ROLE_TOKENS = ['guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||
|
||||
protected $table = 'user_access_profiles';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $allowedFields = [
|
||||
'user_id',
|
||||
'primary_category',
|
||||
'is_admin',
|
||||
'is_teacher',
|
||||
'is_parent',
|
||||
'role_names',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
public function syncUser(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $this->rolesForUser($userId);
|
||||
$flags = self::flagsForRoles($roles);
|
||||
$now = utc_now();
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'primary_category' => self::primaryCategory($flags),
|
||||
'is_admin' => $flags['is_admin'] ? 1 : 0,
|
||||
'is_teacher' => $flags['is_teacher'] ? 1 : 0,
|
||||
'is_parent' => $flags['is_parent'] ? 1 : 0,
|
||||
'role_names' => implode(', ', array_values(array_unique(array_map(
|
||||
static fn (array $role): string => (string) ($role['name'] ?? ''),
|
||||
$roles
|
||||
)))),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$existing = $this->where('user_id', $userId)->first();
|
||||
if ($existing) {
|
||||
$this->update((int) $existing['id'], $data);
|
||||
return $this->find((int) $existing['id']);
|
||||
}
|
||||
|
||||
$data['created_at'] = $now;
|
||||
$id = $this->insert($data);
|
||||
|
||||
return $id ? $this->find((int) $id) : null;
|
||||
}
|
||||
|
||||
public function syncAll(): void
|
||||
{
|
||||
if (! $this->db->tableExists('users') || ! $this->db->tableExists($this->table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $this->db->table('users')->select('id')->get()->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$this->syncUser((int) ($row['id'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
public function getForUser(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$profile = $this->where('user_id', $userId)->first();
|
||||
|
||||
return $profile ?: $this->syncUser($userId);
|
||||
}
|
||||
|
||||
public function getUsersByCategory(string $category): array
|
||||
{
|
||||
$field = match (self::normalizeRoleToken($category)) {
|
||||
self::CATEGORY_ADMIN => 'is_admin',
|
||||
self::CATEGORY_TEACHER => 'is_teacher',
|
||||
self::CATEGORY_PARENT => 'is_parent',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($field === null || ! $this->db->tableExists($this->table)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->select('users.*, user_access_profiles.primary_category, user_access_profiles.role_names')
|
||||
->join('users', 'users.id = user_access_profiles.user_id', 'inner')
|
||||
->where($field, 1)
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
public static function flagsForRoles(array $roles): array
|
||||
{
|
||||
$tokens = [];
|
||||
foreach ($roles as $role) {
|
||||
if (is_array($role)) {
|
||||
$tokens[] = self::normalizeRoleToken((string) ($role['slug'] ?? ''));
|
||||
$tokens[] = self::normalizeRoleToken((string) ($role['name'] ?? ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
$tokens[] = self::normalizeRoleToken((string) $role);
|
||||
}
|
||||
|
||||
$tokens = array_values(array_unique(array_filter($tokens)));
|
||||
$isParent = in_array('parent', $tokens, true);
|
||||
$isTeacher = count(array_intersect($tokens, array_map([self::class, 'normalizeRoleToken'], self::TEACHER_ROLE_TOKENS))) > 0;
|
||||
$isAdmin = false;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (! in_array($token, array_map([self::class, 'normalizeRoleToken'], self::NON_ADMIN_ROLE_TOKENS), true)) {
|
||||
$isAdmin = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'is_admin' => $isAdmin,
|
||||
'is_teacher' => $isTeacher,
|
||||
'is_parent' => $isParent,
|
||||
];
|
||||
}
|
||||
|
||||
public static function primaryCategory(array $flags): string
|
||||
{
|
||||
if (! empty($flags['is_admin'])) {
|
||||
return self::CATEGORY_ADMIN;
|
||||
}
|
||||
|
||||
if (! empty($flags['is_teacher'])) {
|
||||
return self::CATEGORY_TEACHER;
|
||||
}
|
||||
|
||||
if (! empty($flags['is_parent'])) {
|
||||
return self::CATEGORY_PARENT;
|
||||
}
|
||||
|
||||
return self::CATEGORY_GUEST;
|
||||
}
|
||||
|
||||
private function rolesForUser(int $userId): array
|
||||
{
|
||||
if (! $this->db->tableExists('user_roles') || ! $this->db->tableExists('roles')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('user_roles ur')
|
||||
->select('r.name, r.slug')
|
||||
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||
->where('ur.user_id', $userId)
|
||||
->where('COALESCE(r.is_active, 1) = 1', null, false);
|
||||
|
||||
if ($this->db->fieldExists('deleted_at', 'user_roles')) {
|
||||
$builder->where('ur.deleted_at', null);
|
||||
}
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
private static function normalizeRoleToken(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
return str_replace([' ', '-'], '_', $value);
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,10 @@ class UserRoleModel extends Model
|
||||
$userId = (int) ($data['data']['user_id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
service('staffDirectorySync')->syncUser($userId);
|
||||
model(UserAccessProfileModel::class)->syncUser($userId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'UserRoleModel staff directory sync failed: ' . $e->getMessage());
|
||||
log_message('error', 'UserRoleModel role-derived sync failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $data;
|
||||
@@ -45,8 +46,9 @@ class UserRoleModel extends Model
|
||||
{
|
||||
try {
|
||||
service('staffDirectorySync')->syncAll();
|
||||
model(UserAccessProfileModel::class)->syncAll();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'UserRoleModel staff directory delete sync failed: ' . $e->getMessage());
|
||||
log_message('error', 'UserRoleModel role-derived delete sync failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Models;
|
||||
|
||||
use App\Models\UserAccessProfileModel;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class UserAccessProfileModelTest extends CIUnitTestCase
|
||||
{
|
||||
public function testTeacherAssistantCountsAsTeacher(): void
|
||||
{
|
||||
$flags = UserAccessProfileModel::flagsForRoles([
|
||||
['name' => 'teacher_assistant', 'slug' => 'teacher_assistant'],
|
||||
]);
|
||||
|
||||
$this->assertFalse($flags['is_admin']);
|
||||
$this->assertTrue($flags['is_teacher']);
|
||||
$this->assertFalse($flags['is_parent']);
|
||||
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
|
||||
}
|
||||
|
||||
public function testAnyNonTeacherStaffRoleCountsAsAdmin(): void
|
||||
{
|
||||
$flags = UserAccessProfileModel::flagsForRoles([
|
||||
['name' => 'head of fa', 'slug' => 'head_of_fa'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($flags['is_admin']);
|
||||
$this->assertFalse($flags['is_teacher']);
|
||||
$this->assertFalse($flags['is_parent']);
|
||||
$this->assertSame('admin', UserAccessProfileModel::primaryCategory($flags));
|
||||
}
|
||||
|
||||
public function testMultiRoleUserKeepsAllAccessFlags(): void
|
||||
{
|
||||
$flags = UserAccessProfileModel::flagsForRoles(['parent', 'teacher']);
|
||||
|
||||
$this->assertFalse($flags['is_admin']);
|
||||
$this->assertTrue($flags['is_teacher']);
|
||||
$this->assertTrue($flags['is_parent']);
|
||||
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user