Skip to main content

headless_lms_models/
roles.rs

1use crate::prelude::*;
2use utoipa::ToSchema;
3
4#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
5#[sqlx(type_name = "user_role", rename_all = "snake_case")]
6pub enum UserRole {
7    Reviewer,
8    Assistant,
9    Teacher,
10    Admin,
11    CourseOrExamCreator,
12    MaterialViewer,
13    TeachingAndLearningServices,
14    StatsViewer,
15    CreditRegistrationAdmin,
16}
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
19pub struct Role {
20    pub is_global: bool,
21    pub organization_id: Option<Uuid>,
22    pub course_id: Option<Uuid>,
23    pub course_instance_id: Option<Uuid>,
24    pub exam_id: Option<Uuid>,
25    pub role: UserRole,
26    pub user_id: Uuid,
27}
28
29impl Role {
30    pub fn is_global(&self) -> bool {
31        self.is_global
32    }
33
34    pub fn is_role_for_organization(&self, organization_id: Uuid) -> bool {
35        self.organization_id
36            .map(|id| id == organization_id)
37            .unwrap_or_default()
38    }
39
40    pub fn is_role_for_course(&self, course_id: Uuid) -> bool {
41        self.course_id.map(|id| id == course_id).unwrap_or_default()
42    }
43
44    pub fn is_role_for_course_instance(&self, course_instance_id: Uuid) -> bool {
45        self.course_instance_id
46            .map(|id| id == course_instance_id)
47            .unwrap_or_default()
48    }
49
50    pub fn is_role_for_exam(&self, exam_id: Uuid) -> bool {
51        self.exam_id.map(|id| id == exam_id).unwrap_or_default()
52    }
53
54    /// Returns a human-readable description of the domain this role applies to
55    pub fn domain_description(&self) -> String {
56        if self.is_global {
57            "Global".to_string()
58        } else if let Some(id) = self.organization_id {
59            format!("Organization {}", id)
60        } else if let Some(id) = self.course_id {
61            format!("Course {}", id)
62        } else if let Some(id) = self.course_instance_id {
63            format!("CourseInstance {}", id)
64        } else if let Some(id) = self.exam_id {
65            format!("Exam {}", id)
66        } else {
67            "Unknown domain".to_string()
68        }
69    }
70}
71
72#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
73#[serde(tag = "tag", content = "id")]
74pub enum RoleDomain {
75    Global,
76    Organization(Uuid),
77    Course(Uuid),
78    CourseInstance(Uuid),
79    Exam(Uuid),
80}
81
82#[derive(Debug, Deserialize, ToSchema)]
83
84pub struct RoleInfo {
85    pub email: String,
86    pub role: UserRole,
87    pub domain: RoleDomain,
88}
89
90#[derive(Debug, Serialize, ToSchema)]
91
92pub struct RoleUser {
93    pub user_id: Uuid,
94    pub first_name: Option<String>,
95    pub last_name: Option<String>,
96    pub email: String,
97    pub role: UserRole,
98}
99
100pub async fn get(conn: &mut PgConnection, domain: RoleDomain) -> ModelResult<Vec<RoleUser>> {
101    let users = match domain {
102        RoleDomain::Global => {
103            sqlx::query_as!(
104                RoleUser,
105                r#"
106SELECT users.id AS "user_id!",
107  user_details.first_name,
108  user_details.last_name,
109  user_details.email,
110  role AS "role!"
111FROM users
112  JOIN roles ON users.id = roles.user_id
113  JOIN user_details ON users.id = user_details.user_id
114WHERE is_global = TRUE
115AND roles.deleted_at IS NULL
116"#,
117            )
118            .fetch_all(conn)
119            .await?
120        }
121        RoleDomain::Organization(id) => {
122            sqlx::query_as!(
123                RoleUser,
124                r#"
125SELECT users.id AS "user_id!",
126  user_details.first_name,
127  user_details.last_name,
128  user_details.email,
129  role
130FROM users
131  JOIN roles ON users.id = roles.user_id
132  JOIN user_details ON users.id = user_details.user_id
133WHERE roles.organization_id = $1
134AND roles.deleted_at IS NULL
135"#,
136                id
137            )
138            .fetch_all(conn)
139            .await?
140        }
141        RoleDomain::Course(id) => {
142            sqlx::query_as!(
143                RoleUser,
144                r#"
145SELECT users.id AS "user_id!",
146  user_details.first_name,
147  user_details.last_name,
148  user_details.email,
149  role
150FROM users
151  JOIN roles ON users.id = roles.user_id
152  JOIN user_details ON users.id = user_details.user_id
153WHERE roles.course_id = $1
154AND roles.deleted_at IS NULL
155"#,
156                id
157            )
158            .fetch_all(conn)
159            .await?
160        }
161        RoleDomain::CourseInstance(id) => {
162            sqlx::query_as!(
163                RoleUser,
164                r#"
165SELECT users.id AS "user_id!",
166  user_details.first_name,
167  user_details.last_name,
168  user_details.email,
169  role
170FROM users
171  JOIN roles ON users.id = roles.user_id
172  JOIN user_details ON users.id = user_details.user_id
173WHERE roles.course_instance_id = $1
174AND roles.deleted_at IS NULL
175"#,
176                id
177            )
178            .fetch_all(conn)
179            .await?
180        }
181        RoleDomain::Exam(id) => {
182            sqlx::query_as!(
183                RoleUser,
184                r#"
185SELECT users.id AS "user_id!",
186  user_details.first_name,
187  user_details.last_name,
188  user_details.email,
189  role
190FROM users
191  JOIN roles ON users.id = roles.user_id
192  JOIN user_details ON users.id = user_details.user_id
193WHERE roles.exam_id = $1
194AND roles.deleted_at IS NULL
195"#,
196                id
197            )
198            .fetch_all(conn)
199            .await?
200        }
201    };
202    Ok(users)
203}
204
205pub async fn insert(
206    conn: &mut PgConnection,
207    user_id: Uuid,
208    role: UserRole,
209    domain: RoleDomain,
210) -> ModelResult<Uuid> {
211    let id = match domain {
212        RoleDomain::Global => {
213            sqlx::query!(
214                "
215INSERT INTO roles (user_id, role, is_global)
216VALUES ($1, $2, True)
217RETURNING *
218",
219                user_id,
220                role as UserRole
221            )
222            .fetch_one(conn)
223            .await?
224            .id
225        }
226        RoleDomain::Organization(id) => {
227            sqlx::query!(
228                "
229INSERT INTO roles (user_id, role, organization_id)
230VALUES ($1, $2, $3)
231RETURNING *
232",
233                user_id,
234                role as UserRole,
235                id
236            )
237            .fetch_one(conn)
238            .await?
239            .id
240        }
241        RoleDomain::Course(id) => {
242            sqlx::query!(
243                "
244INSERT INTO roles (user_id, role, course_id)
245VALUES ($1, $2, $3)
246RETURNING *
247",
248                user_id,
249                role as UserRole,
250                id
251            )
252            .fetch_one(conn)
253            .await?
254            .id
255        }
256        RoleDomain::CourseInstance(id) => {
257            sqlx::query!(
258                "
259INSERT INTO roles (user_id, role, course_instance_id)
260VALUES ($1, $2, $3)
261RETURNING *
262",
263                user_id,
264                role as UserRole,
265                id
266            )
267            .fetch_one(conn)
268            .await?
269            .id
270        }
271        RoleDomain::Exam(id) => {
272            sqlx::query!(
273                "
274INSERT INTO roles (user_id, role, exam_id)
275VALUES ($1, $2, $3)
276RETURNING *
277",
278                user_id,
279                role as UserRole,
280                id
281            )
282            .fetch_one(conn)
283            .await?
284            .id
285        }
286    };
287    Ok(id)
288}
289
290pub async fn remove(
291    conn: &mut PgConnection,
292    user_id: Uuid,
293    role: UserRole,
294    domain: RoleDomain,
295) -> ModelResult<()> {
296    match domain {
297        RoleDomain::Global => {
298            sqlx::query!(
299                "
300UPDATE roles
301SET deleted_at = NOW()
302WHERE user_id = $1
303  AND role = $2
304  AND deleted_at IS NULL
305",
306                user_id,
307                role as UserRole
308            )
309            .execute(conn)
310            .await?;
311        }
312        RoleDomain::Organization(id) => {
313            sqlx::query!(
314                "
315UPDATE roles
316SET deleted_at = NOW()
317WHERE user_id = $1
318  AND role = $2
319  AND organization_id = $3
320  AND deleted_at IS NULL
321",
322                user_id,
323                role as UserRole,
324                id
325            )
326            .execute(conn)
327            .await?;
328        }
329        RoleDomain::Course(id) => {
330            sqlx::query!(
331                "
332UPDATE roles
333SET deleted_at = NOW()
334WHERE user_id = $1
335  AND role = $2
336  AND course_id = $3
337  AND deleted_at IS NULL
338",
339                user_id,
340                role as UserRole,
341                id
342            )
343            .execute(conn)
344            .await?;
345        }
346        RoleDomain::CourseInstance(id) => {
347            sqlx::query!(
348                "
349UPDATE roles
350SET deleted_at = NOW()
351WHERE user_id = $1
352  AND role = $2
353  AND course_instance_id = $3
354  AND deleted_at IS NULL
355",
356                user_id,
357                role as UserRole,
358                id
359            )
360            .execute(conn)
361            .await?;
362        }
363        RoleDomain::Exam(id) => {
364            sqlx::query!(
365                "
366UPDATE roles
367SET deleted_at = NOW()
368WHERE user_id = $1
369  AND role = $2
370  AND exam_id = $3
371  AND deleted_at IS NULL
372",
373                user_id,
374                role as UserRole,
375                id
376            )
377            .execute(conn)
378            .await?;
379        }
380    }
381    Ok(())
382}
383
384pub async fn get_roles(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<Vec<Role>> {
385    let roles = sqlx::query_as!(
386        Role,
387        r#"
388SELECT is_global,
389  organization_id,
390  course_id,
391  course_instance_id,
392  exam_id,
393  role,
394  user_id
395FROM roles
396WHERE user_id = $1
397AND roles.deleted_at IS NULL
398"#,
399        user_id
400    )
401    .fetch_all(conn)
402    .await?;
403    Ok(roles)
404}
405
406pub async fn get_by_user_id_and_course_ids(
407    conn: &mut PgConnection,
408    user_id: Uuid,
409    course_ids: &[Uuid],
410) -> ModelResult<Vec<Role>> {
411    let roles = sqlx::query_as!(
412        Role,
413        r#"
414SELECT is_global,
415  organization_id,
416  course_id,
417  course_instance_id,
418  exam_id,
419  role,
420  user_id
421FROM roles
422WHERE user_id = $1
423  AND course_id = ANY($2)
424  AND deleted_at IS NULL
425"#,
426        user_id,
427        course_ids
428    )
429    .fetch_all(conn)
430    .await?;
431    Ok(roles)
432}
433
434/// Gets all roles related to a specific course.
435/// This includes:
436/// - Global roles
437/// - Organization roles for the organization that owns the course
438/// - Course roles for this specific course
439/// - Course instance roles for any instance of this course
440pub async fn get_course_related_roles(
441    conn: &mut PgConnection,
442    course_id: Uuid,
443) -> ModelResult<Vec<Role>> {
444    let roles = sqlx::query_as!(
445        Role,
446        r#"
447WITH course_org AS (
448  SELECT organization_id
449  FROM courses
450  WHERE id = $1
451    AND deleted_at IS NULL
452)
453SELECT is_global,
454  organization_id,
455  course_id,
456  course_instance_id,
457  exam_id,
458  role,
459  user_id
460FROM roles
461WHERE (
462    is_global = TRUE
463    OR organization_id = (
464      SELECT organization_id
465      FROM course_org
466    )
467    OR course_id = $1
468    OR course_instance_id IN (
469      SELECT id
470      FROM course_instances
471      WHERE course_id = $1
472        AND deleted_at IS NULL
473    )
474  )
475  AND deleted_at IS NULL
476"#,
477        course_id
478    )
479    .fetch_all(conn)
480    .await?;
481
482    Ok(roles)
483}
484
485/// Gets all roles related to any course in a language group.
486/// This includes global roles, organization roles for any organization that has a course in the group,
487/// course roles for any course in the group, and course instance roles for any instance of those courses.
488pub async fn get_course_language_group_related_roles(
489    conn: &mut PgConnection,
490    course_language_group_id: Uuid,
491) -> ModelResult<Vec<Role>> {
492    let roles = sqlx::query_as!(
493        Role,
494        r#"
495WITH course_org AS (
496  SELECT DISTINCT organization_id
497  FROM courses
498  WHERE course_language_group_id = $1
499    AND deleted_at IS NULL
500)
501SELECT is_global,
502  organization_id,
503  course_id,
504  course_instance_id,
505  exam_id,
506  role,
507  user_id
508FROM roles
509WHERE (
510    is_global = TRUE
511    OR organization_id IN (
512      SELECT organization_id
513      FROM course_org
514    )
515    OR course_id IN (
516      SELECT id
517      FROM courses
518      WHERE course_language_group_id = $1
519        AND deleted_at IS NULL
520    )
521    OR course_instance_id IN (
522      SELECT ci.id
523      FROM course_instances ci
524        JOIN courses c ON ci.course_id = c.id
525      WHERE c.course_language_group_id = $1
526        AND ci.deleted_at IS NULL
527    )
528  )
529  AND deleted_at IS NULL
530"#,
531        course_language_group_id
532    )
533    .fetch_all(conn)
534    .await?;
535
536    Ok(roles)
537}