Skip to content

Commit 2a45dcc

Browse files
committed
Merge #805: 801 adding filtering and sorting options to listing user profiles endpoint
55bbace refactor: [#801] refactor names (Mario) 83d4e60 fix: [#801] fixed authorization definition (Mario) 618f857 refactor: [#801] more code cleanup (Mario) c6fa4b6 refactor: [#801] removed unused code (Mario) db43aec refactor: [#801] code cleanup (Mario) e963c7f refactor: [#801] refactor filtering code logic (Mario) 42ebedb refactor: [#801] refactor sorting code logic (Mario) fca5fe1 feat: [#801] new service error for invalid user listing (Mario) 7cd3fc1 refactor: [#801] fixed 500 error when using two where filters at the same time and code cleanup (Mario) d95dcaa refactor: [#801] restore error handling to previous version (Mario) a30544d refactor: [#801] fixed clippy warnings (Mario) b8fde66 refactor: [#801] fixed filtering and sorting not working (Mario) 5400849 feat: [#801] new user listing type (Mario) 534a2ee feat: [#801] added filtering for both drivers (Mario) 183d27b feat: [#801] added sorting to query in mysql driver (Mario) Pull request description: Resolves #801 ACKs for top commit: josecelano: ACK 55bbace Tree-SHA512: d5d0c06ff19fcc3ed889e05464043bdd758f2409576dec19d296d6aad8a137c4e3e245a245675df80367b506d8ab2517c751c8508fdbeb74a64f55469e969052
2 parents f2c6118 + 55bbace commit 2a45dcc

File tree

9 files changed

+261
-43
lines changed

9 files changed

+261
-43
lines changed

src/databases/database.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
use std::fmt;
2+
use std::str::FromStr;
3+
14
use async_trait::async_trait;
25
use bittorrent_primitives::info_hash::InfoHash;
36
use chrono::{DateTime, NaiveDateTime, Utc};
@@ -73,6 +76,74 @@ pub enum Sorting {
7376
SizeDesc,
7477
}
7578

79+
/// Sorting options for users.
80+
#[derive(Clone, Copy, Debug, Deserialize)]
81+
pub enum UsersSorting {
82+
DateRegisteredNewest,
83+
DateRegisteredOldest,
84+
UsernameAZ,
85+
UsernameZA,
86+
}
87+
88+
impl FromStr for UsersSorting {
89+
type Err = UsersSortingParseError;
90+
91+
fn from_str(s: &str) -> Result<Self, Self::Err> {
92+
match s {
93+
"DateRegisteredNewest" => Ok(UsersSorting::DateRegisteredNewest),
94+
"DateRegisteredOldest" => Ok(UsersSorting::DateRegisteredOldest),
95+
"UsernameAZ" => Ok(UsersSorting::UsernameAZ),
96+
"UsernameZA" => Ok(UsersSorting::UsernameZA),
97+
_ => Err(UsersSortingParseError),
98+
}
99+
}
100+
}
101+
102+
// Custom error type for parsing failures
103+
#[derive(Debug)]
104+
pub struct UsersSortingParseError;
105+
106+
impl fmt::Display for UsersSortingParseError {
107+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108+
write!(f, "Invalid sorting option")
109+
}
110+
}
111+
112+
impl std::error::Error for UsersSortingParseError {}
113+
114+
/// Sorting options for users.
115+
#[derive(Clone, Copy, Debug, Deserialize)]
116+
pub enum UsersFilters {
117+
EmailVerified,
118+
EmailNotVerified,
119+
TorrentUploader,
120+
}
121+
122+
impl FromStr for UsersFilters {
123+
type Err = UsersFiltersParseError;
124+
125+
fn from_str(s: &str) -> Result<Self, Self::Err> {
126+
match s {
127+
"EmailVerified" => Ok(UsersFilters::EmailVerified),
128+
"EmailNotVerified" => Ok(UsersFilters::EmailNotVerified),
129+
"TorrentUploader" => Ok(UsersFilters::TorrentUploader),
130+
_ => Err(UsersFiltersParseError),
131+
}
132+
}
133+
}
134+
135+
// Custom error type for parsing failures
136+
#[derive(Debug)]
137+
pub struct UsersFiltersParseError;
138+
139+
impl fmt::Display for UsersFiltersParseError {
140+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141+
write!(f, "Invalid filter option")
142+
}
143+
}
144+
145+
impl std::error::Error for UsersFiltersParseError {}
146+
76147
/// Database errors.
77148
#[derive(Debug)]
78149
pub enum Error {
@@ -143,10 +214,12 @@ pub trait Database: Sync + Send {
143214
/// Get `UserProfile` from `username`.
144215
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;
145216

146-
/// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`,`offset` and `page_size`.
217+
/// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`, `filters`, `sort`, `offset` and `page_size`.
147218
async fn get_user_profiles_search_paginated(
148219
&self,
149220
search: &Option<String>,
221+
filters: &Option<Vec<UsersFilters>>,
222+
sort: Option<UsersSorting>,
150223
offset: u64,
151224
page_size: u8,
152225
) -> Result<UserProfilesResponse, Error>;

src/databases/mysql.rs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
88
use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool};
99
use url::Url;
1010

11-
use super::database::TABLES_TO_TRUNCATE;
11+
use super::database::{UsersFilters, UsersSorting, TABLES_TO_TRUNCATE};
1212
use crate::databases::database;
1313
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
1414
use crate::models::category::CategoryId;
@@ -19,7 +19,7 @@ use crate::models::torrent_file::{
1919
};
2020
use crate::models::torrent_tag::{TagId, TorrentTag};
2121
use crate::models::tracker_key::TrackerKey;
22-
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile};
22+
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserListing, UserProfile};
2323
use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash};
2424
use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT};
2525
use crate::utils::hex::from_bytes;
@@ -158,6 +158,8 @@ impl Database for Mysql {
158158
async fn get_user_profiles_search_paginated(
159159
&self,
160160
search: &Option<String>,
161+
filters: &Option<Vec<UsersFilters>>,
162+
sort: Option<UsersSorting>,
161163
offset: u64,
162164
limit: u8,
163165
) -> Result<UserProfilesResponse, database::Error> {
@@ -166,7 +168,46 @@ impl Database for Mysql {
166168
Some(v) => format!("%{v}%"),
167169
};
168170

169-
let mut query_string = "SELECT * FROM torrust_user_profiles WHERE username LIKE ?".to_string();
171+
let sort_query: String = match sort {
172+
Some(UsersSorting::DateRegisteredNewest) => "date_registered ASC".to_string(),
173+
Some(UsersSorting::DateRegisteredOldest) => "date_registered DESC".to_string(),
174+
Some(UsersSorting::UsernameAZ) | None => "username ASC".to_string(),
175+
Some(UsersSorting::UsernameZA) => "username DESC".to_string(),
176+
};
177+
178+
let (join_filters, where_filters) = if let Some(filters) = filters {
179+
let (mut join_filters_query, mut where_filters_query) = (String::new(), String::new());
180+
for filter in filters {
181+
match filter {
182+
UsersFilters::TorrentUploader => join_filters_query.push_str(
183+
"INNER JOIN torrust_torrents tt
184+
ON tu.user_id = tt.uploader_id ",
185+
),
186+
UsersFilters::EmailNotVerified => where_filters_query.push_str(" AND email_verified = false"),
187+
UsersFilters::EmailVerified => where_filters_query.push_str(" AND email_verified = true"),
188+
}
189+
}
190+
(join_filters_query, where_filters_query)
191+
} else {
192+
(String::new(), String::new())
193+
};
194+
195+
let mut query_string = format!(
196+
"SELECT
197+
tp.user_id,
198+
tp.username,
199+
tp.email,
200+
tp.email_verified,
201+
tu.date_registered,
202+
tu.administrator
203+
FROM torrust_user_profiles tp
204+
INNER JOIN torrust_users tu
205+
ON tp.user_id = tu.user_id
206+
{join_filters}
207+
WHERE username LIKE ?
208+
{where_filters}
209+
"
210+
);
170211

171212
let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");
172213

@@ -179,9 +220,9 @@ impl Database for Mysql {
179220

180221
let count = count_result?;
181222

182-
query_string = format!("{query_string} LIMIT ?, ?");
223+
query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?");
183224

184-
let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
225+
let res: Vec<UserListing> = sqlx::query_as::<_, UserListing>(&query_string)
185226
.bind(user_name.clone())
186227
.bind(i64::saturating_add_unsigned(0, offset))
187228
.bind(limit)

src/databases/sqlite.rs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
88
use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool};
99
use url::Url;
1010

11-
use super::database::TABLES_TO_TRUNCATE;
11+
use super::database::{UsersFilters, UsersSorting, TABLES_TO_TRUNCATE};
1212
use crate::databases::database;
1313
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
1414
use crate::models::category::CategoryId;
@@ -19,7 +19,7 @@ use crate::models::torrent_file::{
1919
};
2020
use crate::models::torrent_tag::{TagId, TorrentTag};
2121
use crate::models::tracker_key::TrackerKey;
22-
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile};
22+
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserListing, UserProfile};
2323
use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash};
2424
use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT};
2525
use crate::utils::hex::from_bytes;
@@ -159,6 +159,8 @@ impl Database for Sqlite {
159159
async fn get_user_profiles_search_paginated(
160160
&self,
161161
search: &Option<String>,
162+
filters: &Option<Vec<UsersFilters>>,
163+
sort: Option<UsersSorting>,
162164
offset: u64,
163165
limit: u8,
164166
) -> Result<UserProfilesResponse, database::Error> {
@@ -167,7 +169,46 @@ impl Database for Sqlite {
167169
Some(v) => format!("%{v}%"),
168170
};
169171

170-
let mut query_string = "SELECT * FROM torrust_user_profiles WHERE username LIKE ?".to_string();
172+
let sort_query: String = match sort {
173+
Some(UsersSorting::DateRegisteredNewest) => "date_registered ASC".to_string(),
174+
Some(UsersSorting::DateRegisteredOldest) => "date_registered DESC".to_string(),
175+
Some(UsersSorting::UsernameAZ) | None => "username ASC".to_string(),
176+
Some(UsersSorting::UsernameZA) => "username DESC".to_string(),
177+
};
178+
179+
let (join_filters, where_filters) = if let Some(filters) = filters {
180+
let (mut join_filters_query, mut where_filters_query) = (String::new(), String::new());
181+
for filter in filters {
182+
match filter {
183+
UsersFilters::TorrentUploader => join_filters_query.push_str(
184+
"INNER JOIN torrust_torrents tt
185+
ON tu.user_id = tt.uploader_id ",
186+
),
187+
UsersFilters::EmailNotVerified => where_filters_query.push_str(" AND email_verified = false"),
188+
UsersFilters::EmailVerified => where_filters_query.push_str(" AND email_verified = true"),
189+
}
190+
}
191+
(join_filters_query, where_filters_query)
192+
} else {
193+
(String::new(), String::new())
194+
};
195+
196+
let mut query_string = format!(
197+
"SELECT
198+
tp.user_id,
199+
tp.username,
200+
tp.email,
201+
tp.email_verified,
202+
tu.date_registered,
203+
tu.administrator
204+
FROM torrust_user_profiles tp
205+
INNER JOIN torrust_users tu
206+
ON tp.user_id = tu.user_id
207+
{join_filters}
208+
WHERE username LIKE ?
209+
{where_filters}
210+
"
211+
);
171212

172213
let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");
173214

@@ -180,9 +221,9 @@ impl Database for Sqlite {
180221

181222
let count = count_result?;
182223

183-
query_string = format!("{query_string} LIMIT ?, ?");
224+
query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?");
184225

185-
let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
226+
let res: Vec<UserListing> = sqlx::query_as::<_, UserListing>(&query_string)
186227
.bind(user_name.clone())
187228
.bind(i64::saturating_add_unsigned(0, offset))
188229
.bind(limit)

src/errors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ pub enum ServiceError {
175175
#[display("Invalid tracker API token.")]
176176
InvalidTrackerToken,
177177
// End tracker errors
178+
#[display("Invalid user listing fields in the URL params.")]
179+
InvalidUserListing,
178180
}
179181

180182
impl From<sqlx::Error> for ServiceError {
@@ -326,6 +328,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode {
326328
ServiceError::TorrentNotFoundInTracker => StatusCode::NOT_FOUND,
327329
ServiceError::InvalidTrackerToken => StatusCode::INTERNAL_SERVER_ERROR,
328330
ServiceError::LoggedInUserNotFound => StatusCode::UNAUTHORIZED,
331+
ServiceError::InvalidUserListing => StatusCode::BAD_REQUEST,
329332
}
330333
}
331334

src/models/response.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use url::Url;
33

44
use super::category::Category;
55
use super::torrent::TorrentId;
6-
use super::user::UserProfile;
6+
use super::user::UserListing;
77
use crate::databases::database::Category as DatabaseCategory;
88
use crate::models::torrent::TorrentListing;
99
use crate::models::torrent_file::TorrentFile;
@@ -129,5 +129,5 @@ pub struct TorrentsResponse {
129129
#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)]
130130
pub struct UserProfilesResponse {
131131
pub total: u32,
132-
pub results: Vec<UserProfile>,
132+
pub results: Vec<UserListing>,
133133
}

src/models/user.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ pub struct UserFull {
5555
pub avatar: String,
5656
}
5757

58+
#[allow(clippy::module_name_repetitions)]
59+
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)]
60+
pub struct UserListing {
61+
pub user_id: UserId,
62+
pub username: String,
63+
pub email: String,
64+
pub email_verified: bool,
65+
pub date_registered: String,
66+
pub administrator: bool,
67+
}
68+
5869
#[allow(clippy::module_name_repetitions)]
5970
#[derive(Debug, Serialize, Deserialize, Clone)]
6071
pub struct UserClaims {

src/services/authorization.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ pub enum ACTION {
5252
GetCanonicalInfoHash,
5353
ChangePassword,
5454
BanUser,
55-
GenerateUserProfilesListing,
55+
GenerateUserProfileSpecification,
5656
}
5757

5858
pub struct Service {
@@ -249,7 +249,7 @@ impl Default for CasbinConfiguration {
249249
admin, GetCanonicalInfoHash
250250
admin, ChangePassword
251251
admin, BanUser
252-
admin, GenerateUserProfilesListing
252+
admin, GenerateUserProfileSpecification
253253
registered, GetAboutPage
254254
registered, GetLicensePage
255255
registered, GetCategories

0 commit comments

Comments
 (0)