Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

801 adding filtering and sorting options to listing user profiles endpoint #805

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
24 changes: 23 additions & 1 deletion src/databases/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ pub enum Sorting {
SizeDesc,
}

/// Sorting options for users.
#[derive(Clone, Copy, Debug, Deserialize)]
pub enum UsersSorting {
DateRegisteredNewest,
DateRegisteredOldest,
UsernameAZ,
UsernameZA,
}

/// Sorting options for users.
#[derive(Clone, Copy, Debug, Deserialize)]
pub enum UsersFilters {
EmailVerified,
EmailNotVerified,
TorrentUploader,
}

/// Database errors.
#[derive(Debug)]
pub enum Error {
Expand Down Expand Up @@ -143,17 +160,22 @@ pub trait Database: Sync + Send {
/// Get `UserProfile` from `username`.
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;

/// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`,`offset` and `page_size`.
/// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`, `filters`, `sort`, `offset` and `page_size`.
async fn get_user_profiles_search_paginated(
&self,
search: &Option<String>,
filters: &Option<Vec<String>>,
sort: &UsersSorting,
offset: u64,
page_size: u8,
) -> Result<UserProfilesResponse, Error>;

/// Get `UserCompact` from `user_id`.
async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, Error>;

/// Get `UsersFilter` from `filter_name`.
async fn get_filters_from_name(&self, filter_name: &str) -> Option<UsersFilters>;

/// Get a user's `TrackerKey`.
async fn get_user_tracker_key(&self, user_id: i64) -> Option<TrackerKey>;

Expand Down
84 changes: 79 additions & 5 deletions src/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool};
use url::Url;

use super::database::TABLES_TO_TRUNCATE;
use super::database::{UsersFilters, UsersSorting, TABLES_TO_TRUNCATE};
use crate::databases::database;
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
use crate::models::category::CategoryId;
Expand All @@ -19,7 +19,7 @@ use crate::models::torrent_file::{
};
use crate::models::torrent_tag::{TagId, TorrentTag};
use crate::models::tracker_key::TrackerKey;
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile};
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserListing, UserProfile};
use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash};
use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT};
use crate::utils::hex::from_bytes;
Expand Down Expand Up @@ -158,6 +158,8 @@ impl Database for Mysql {
async fn get_user_profiles_search_paginated(
&self,
search: &Option<String>,
filters: &Option<Vec<String>>,
sort: &UsersSorting,
offset: u64,
limit: u8,
) -> Result<UserProfilesResponse, database::Error> {
Expand All @@ -166,7 +168,70 @@ impl Database for Mysql {
Some(v) => format!("%{v}%"),
};

let mut query_string = "SELECT * FROM torrust_user_profiles WHERE username LIKE ?".to_string();
let sort_query: String = match sort {
UsersSorting::DateRegisteredNewest => "date_registered ASC".to_string(),
UsersSorting::DateRegisteredOldest => "date_registered DESC".to_string(),
UsersSorting::UsernameAZ => "username ASC".to_string(),
UsersSorting::UsernameZA => "username DESC".to_string(),
};

let join_filters_query = if let Some(filters) = filters {
let mut join_filters = String::new();
for filter in filters {
// don't take user input in the db query to smt join filter query
if let Some(sanitized_filter) = self.get_filters_from_name(filter).await {
match sanitized_filter {
UsersFilters::TorrentUploader => join_filters.push_str(
"INNER JOIN torrust_torrents tt
ON tu.user_id = tt.uploader_id",
),
_ => break,
}
}
}
join_filters
} else {
String::new()
};

let where_filters_query = if let Some(filters) = filters {
let mut where_filters = String::new();
for filter in filters {
// don't take user input in the db query
if let Some(sanitized_filter) = self.get_filters_from_name(filter).await {
let mut filter_query = String::new();
match sanitized_filter {
UsersFilters::EmailNotVerified => filter_query.push_str("email_verified = false"),
UsersFilters::EmailVerified => filter_query.push_str("email_verified = true"),
_ => continue,
};

let str = format!("AND {filter_query} ");

where_filters.push_str(&str);
}
}
where_filters
} else {
String::new()
};

let mut query_string = format!(
"SELECT
tp.user_id,
tp.username,
tp.email,
tp.email_verified,
tu.date_registered,
tu.administrator
FROM torrust_user_profiles tp
INNER JOIN torrust_users tu
ON tp.user_id = tu.user_id
{join_filters_query}
WHERE username LIKE ?
{where_filters_query}
"
);

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

Expand All @@ -179,9 +244,9 @@ impl Database for Mysql {

let count = count_result?;

query_string = format!("{query_string} LIMIT ?, ?");
query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?");

let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
let res: Vec<UserListing> = sqlx::query_as::<_, UserListing>(&query_string)
.bind(user_name.clone())
.bind(i64::saturating_add_unsigned(0, offset))
.bind(limit)
Expand All @@ -203,6 +268,15 @@ impl Database for Mysql {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_filters_from_name(&self, filter_name: &str) -> Option<UsersFilters> {
match filter_name {
"torrentUploader" => Some(UsersFilters::TorrentUploader),
"emailNotVerified" => Some(UsersFilters::EmailNotVerified),
"emailVerified" => Some(UsersFilters::EmailVerified),
_ => None,
}
}

/// Gets User Tracker Key
///
/// # Panics
Expand Down
84 changes: 79 additions & 5 deletions src/databases/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool};
use url::Url;

use super::database::TABLES_TO_TRUNCATE;
use super::database::{UsersFilters, UsersSorting, TABLES_TO_TRUNCATE};
use crate::databases::database;
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
use crate::models::category::CategoryId;
Expand All @@ -19,7 +19,7 @@ use crate::models::torrent_file::{
};
use crate::models::torrent_tag::{TagId, TorrentTag};
use crate::models::tracker_key::TrackerKey;
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile};
use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserListing, UserProfile};
use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash};
use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT};
use crate::utils::hex::from_bytes;
Expand Down Expand Up @@ -159,6 +159,8 @@ impl Database for Sqlite {
async fn get_user_profiles_search_paginated(
&self,
search: &Option<String>,
filters: &Option<Vec<String>>,
sort: &UsersSorting,
offset: u64,
limit: u8,
) -> Result<UserProfilesResponse, database::Error> {
Expand All @@ -167,7 +169,70 @@ impl Database for Sqlite {
Some(v) => format!("%{v}%"),
};

let mut query_string = "SELECT * FROM torrust_user_profiles WHERE username LIKE ?".to_string();
let sort_query: String = match sort {
UsersSorting::DateRegisteredNewest => "date_registered ASC".to_string(),
UsersSorting::DateRegisteredOldest => "date_registered DESC".to_string(),
UsersSorting::UsernameAZ => "username ASC".to_string(),
UsersSorting::UsernameZA => "username DESC".to_string(),
};

let join_filters_query = if let Some(filters) = filters {
let mut join_filters = String::new();
for filter in filters {
// don't take user input in the db query
if let Some(sanitized_filter) = self.get_filters_from_name(filter).await {
match sanitized_filter {
UsersFilters::TorrentUploader => join_filters.push_str(
"INNER JOIN torrust_torrents tt
ON tu.user_id = tt.uploader_id ",
),
_ => break,
}
}
}
join_filters
} else {
String::new()
};

let where_filters_query = if let Some(filters) = filters {
let mut where_filters = String::new();
for filter in filters {
// don't take user input in the db query
if let Some(sanitized_filter) = self.get_filters_from_name(filter).await {
let mut filter_query = String::new();
match sanitized_filter {
UsersFilters::EmailNotVerified => filter_query.push_str("email_verified = false"),
UsersFilters::EmailVerified => filter_query.push_str("email_verified = true"),
_ => continue,
};

let str = format!("AND {filter_query} ");

where_filters.push_str(&str);
}
}
where_filters
} else {
String::new()
};

let mut query_string = format!(
"SELECT
tp.user_id,
tp.username,
tp.email,
tp.email_verified,
tu.date_registered,
tu.administrator
FROM torrust_user_profiles tp
INNER JOIN torrust_users tu
ON tp.user_id = tu.user_id
{join_filters_query}
WHERE username LIKE ?
{where_filters_query}
"
);

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

Expand All @@ -180,9 +245,9 @@ impl Database for Sqlite {

let count = count_result?;

query_string = format!("{query_string} LIMIT ?, ?");
query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?");

let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
let res: Vec<UserListing> = sqlx::query_as::<_, UserListing>(&query_string)
.bind(user_name.clone())
.bind(i64::saturating_add_unsigned(0, offset))
.bind(limit)
Expand All @@ -204,6 +269,15 @@ impl Database for Sqlite {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_filters_from_name(&self, filter_name: &str) -> Option<UsersFilters> {
match filter_name {
"torrentUploader" => Some(UsersFilters::TorrentUploader),
"emailNotVerified" => Some(UsersFilters::EmailNotVerified),
"emailVerified" => Some(UsersFilters::EmailVerified),
_ => None,
}
}

async fn get_user_tracker_key(&self, user_id: i64) -> Option<TrackerKey> {
const HOUR_IN_SECONDS: i64 = 3600;

Expand Down
3 changes: 3 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ pub enum ServiceError {
#[display("Invalid tracker API token.")]
InvalidTrackerToken,
// End tracker errors
#[display("Invalid user listing fields in the URL params.")]
InvalidUserListing,
}

impl From<sqlx::Error> for ServiceError {
Expand Down Expand Up @@ -326,6 +328,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode {
ServiceError::TorrentNotFoundInTracker => StatusCode::NOT_FOUND,
ServiceError::InvalidTrackerToken => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::LoggedInUserNotFound => StatusCode::UNAUTHORIZED,
ServiceError::InvalidUserListing => StatusCode::BAD_REQUEST,
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/models/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use url::Url;

use super::category::Category;
use super::torrent::TorrentId;
use super::user::UserProfile;
use super::user::UserListing;
use crate::databases::database::Category as DatabaseCategory;
use crate::models::torrent::TorrentListing;
use crate::models::torrent_file::TorrentFile;
Expand Down Expand Up @@ -129,5 +129,5 @@ pub struct TorrentsResponse {
#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)]
pub struct UserProfilesResponse {
pub total: u32,
pub results: Vec<UserProfile>,
pub results: Vec<UserListing>,
}
11 changes: 11 additions & 0 deletions src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ pub struct UserFull {
pub avatar: String,
}

#[allow(clippy::module_name_repetitions)]
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct UserListing {
pub user_id: UserId,
pub username: String,
pub email: String,
pub email_verified: bool,
pub date_registered: String,
pub administrator: bool,
}

#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserClaims {
Expand Down
Loading
Loading