diff --git a/src/commands/install.rs b/src/commands/install.rs index 5694660..1ddb7a0 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -1,5 +1,6 @@ use crate::{ commands::GlobalArgs, + config::{write_project_config_default, ProjectConfig}, dirs::{project_config_dir, project_data_dir, project_use_case_toml_path, read_pyproject}, download::download_archive, error::{self, Result}, @@ -89,6 +90,14 @@ pub async fn install_submission( ) })?; + write_project_config_default( + &global.project, + &ProjectConfig { + show_score: competition.has_leaderboard, + }, + ) + .await?; + let use_case_toml_path = project_use_case_toml_path(&global.project); let old_use_case = if use_case_toml_path.exists() { Some(PyProject::from_toml( diff --git a/src/commands/test.rs b/src/commands/test.rs index 78a2ad3..1bbf842 100644 --- a/src/commands/test.rs +++ b/src/commands/test.rs @@ -1,5 +1,6 @@ use crate::{ commands::GlobalArgs, + config::read_project_config, dirs::{ project_data_dir, project_last_run_dir, project_last_run_result, project_use_case_toml_path, read_pyproject, @@ -218,6 +219,8 @@ pub async fn run_submission_tests( .and_then(|aqora| aqora.as_submission()) .ok_or_else(|| error::user("Submission config is not valid", ""))?; + let project_config = read_project_config(&global.project).await?; + let use_case_toml_path = project_use_case_toml_path(&global.project); let data_path = project_data_dir(&global.project, "data"); if !use_case_toml_path.exists() || !data_path.exists() { @@ -340,11 +343,13 @@ pub async fn run_submission_tests( let result = match aggregated { Ok(Some(score)) => { - pipeline_pb.println(format!( - "{}: {}", - "Score".if_supports_color(OwoStream::Stdout, |text| { text.bold() }), - score - )); + if project_config.show_score { + pipeline_pb.println(format!( + "{}: {}", + "Score".if_supports_color(OwoStream::Stdout, |text| { text.bold() }), + score + )); + } pipeline_pb.finish_and_clear(); Ok(score) } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..861d441 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,301 @@ +use std::io; +use std::path::{Path, PathBuf}; + +use futures::prelude::*; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use thiserror::Error; +use toml_edit::DocumentMut; + +use crate::dirs::project_config_file_path; +use crate::error::{system, user, Error}; + +lazy_static::lazy_static! { +static ref DEFAULT_TEMPLATE: DocumentMut = r#"# Project configuration + +# The default configuration set by the competition +[default] + +# User specific overrides +[user] +"#.parse::().unwrap(); +} + +fn merge_toml_value(left: toml::Value, right: toml::Value) -> toml::Value { + match (left, right) { + (toml::Value::Table(mut left), toml::Value::Table(right)) => { + for (key, right_value) in right { + if let Some(left_value) = left.remove(&key) { + left.insert(key, merge_toml_value(left_value, right_value)); + } else { + left.insert(key, right_value); + } + } + toml::Value::Table(left) + } + (_, right) => right, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConfigFile { + default: Option, + user: Option, +} + +impl ConfigFile { + fn merged(self) -> toml::Value { + match (self.default, self.user) { + (Some(default), Some(user)) => merge_toml_value(default, user), + (None, Some(user)) => user, + (Some(default), None) => default, + (None, None) => toml::Value::Table(Default::default()), + } + } + + fn try_into(self) -> Result + where + T: DeserializeOwned, + { + self.merged().try_into() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ProjectConfig { + pub show_score: bool, +} + +impl Default for ProjectConfig { + fn default() -> Self { + Self { show_score: true } + } +} + +#[derive(Debug, Error)] +pub enum ReadProjectConfigError { + #[error("Could not read project configuration file '{0}': {1}")] + Io(PathBuf, #[source] io::Error), + #[error("Project configuration file '{0}' is invalid: {1}")] + Invalid(PathBuf, #[source] toml::de::Error), +} + +impl From for Error { + fn from(value: ReadProjectConfigError) -> Self { + match &value { + ReadProjectConfigError::Io(..) => system( + &value.to_string(), + "Check that the file exists and you have permissions to read it", + ), + ReadProjectConfigError::Invalid(..) => { + user(&value.to_string(), "Make sure the file is valid toml") + } + } + } +} +async fn read_config_file(path: impl AsRef) -> Result +where + T: DeserializeOwned + Default, +{ + let path = path.as_ref(); + if tokio::fs::try_exists(path) + .await + .map_err(|e| ReadProjectConfigError::Io(path.to_path_buf(), e))? + { + let string = tokio::fs::read_to_string(path) + .await + .map_err(|e| ReadProjectConfigError::Io(path.to_path_buf(), e))?; + let file: ConfigFile = toml::from_str(&string) + .map_err(|e| ReadProjectConfigError::Invalid(path.to_path_buf(), e))?; + file.try_into() + .map_err(|e| ReadProjectConfigError::Invalid(path.to_path_buf(), e)) + } else { + Ok(Default::default()) + } +} + +pub async fn read_project_config( + project_dir: impl AsRef, +) -> Result { + read_config_file(project_config_file_path(project_dir)).await +} + +#[derive(Debug, Error)] +pub enum WriteProjectConfigError { + #[error("Could not read project configuration file '{0}': {1}")] + ReadIo(PathBuf, #[source] io::Error), + #[error("Could not write project configuration file '{0}': {1}")] + WriteIo(PathBuf, #[source] io::Error), + #[error("Invalid project configuration file '{0}': {1}")] + InvalidExisting(PathBuf, #[source] toml_edit::TomlError), + #[error("Invalid new configuration: {0}")] + InvalidNew(#[source] toml_edit::ser::Error), +} + +impl From for Error { + fn from(value: WriteProjectConfigError) -> Self { + match &value { + WriteProjectConfigError::ReadIo(..) => system( + &value.to_string(), + "Check that the you have permissions to read it", + ), + WriteProjectConfigError::WriteIo(..) => system( + &value.to_string(), + "Check that the you have permissions to write to it", + ), + WriteProjectConfigError::InvalidExisting(..) => { + user(&value.to_string(), "Make sure the file is valid toml") + } + WriteProjectConfigError::InvalidNew(..) => { + user(&value.to_string(), "Make sure the new config is valid") + } + } + } +} + +async fn write_config_file_default( + path: impl AsRef, + config: &T, +) -> Result<(), WriteProjectConfigError> +where + T: Serialize, +{ + let path = path.as_ref(); + let config_value = + toml_edit::ser::to_document(&config).map_err(WriteProjectConfigError::InvalidNew)?; + let mut doc = if let Some(doc) = tokio::fs::try_exists(path) + .and_then(|exists| async move { + Ok(if exists { + let doc = tokio::fs::read_to_string(path).await?; + if doc.trim().is_empty() { + None + } else { + Some(doc) + } + } else { + None + }) + }) + .await + .map_err(|e| WriteProjectConfigError::ReadIo(path.to_path_buf(), e))? + { + doc.parse::() + .map_err(|e| WriteProjectConfigError::InvalidExisting(path.to_path_buf(), e))? + } else { + DEFAULT_TEMPLATE.clone() + }; + if let Some(value) = doc.get_mut("default") { + if let Some(table) = value.as_table_mut() { + table.extend(config_value.as_table()) + } else { + *value = config_value.as_item().clone() + } + } else { + doc.insert("default", config_value.as_item().clone()); + } + tokio::fs::write(path, doc.to_string()) + .await + .map_err(|e| WriteProjectConfigError::WriteIo(path.to_path_buf(), e))?; + Ok(()) +} + +pub async fn write_project_config_default( + project_dir: impl AsRef, + config: &ProjectConfig, +) -> Result<(), WriteProjectConfigError> { + write_config_file_default(project_config_file_path(project_dir), config).await +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[derive(Debug, Serialize, Deserialize, Default)] + #[serde(default)] + pub struct ExampleConfig { + setting: bool, + } + + #[tokio::test] + async fn test_write_config() -> Result<(), Error> { + let file = NamedTempFile::new()?; + write_config_file_default(file.path(), &ExampleConfig { setting: false }).await?; + let written = tokio::fs::read_to_string(file.path()).await?; + assert_eq!( + written, + r#"# Project configuration + +# The default configuration set by the competition +[default] +setting = false + +# User specific overrides +[user] +"# + ); + write_config_file_default(file.path(), &ExampleConfig { setting: true }).await?; + let written = tokio::fs::read_to_string(file.path()).await?; + assert_eq!( + written, + r#"# Project configuration + +# The default configuration set by the competition +[default] +setting = true + +# User specific overrides +[user] +"# + ); + Ok(()) + } + + #[tokio::test] + async fn test_read_config() -> Result<(), Error> { + let file = NamedTempFile::new()?; + assert!( + !read_config_file::(file.path()) + .await? + .setting + ); + tokio::fs::write( + file.path(), + r#"# Project configuration + +# The default configuration set by the competition +[default] +setting = true + +# User specific overrides +[user] +"#, + ) + .await?; + assert!( + read_config_file::(file.path()) + .await? + .setting + ); + tokio::fs::write( + file.path(), + r#"# Project configuration + +# The default configuration set by the competition +[default] +setting = true + +# User specific overrides +[user] +setting = false +"#, + ) + .await?; + assert!( + !read_config_file::(file.path()) + .await? + .setting + ); + Ok(()) + } +} diff --git a/src/dirs.rs b/src/dirs.rs index 15fbc1e..a9ebd1a 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -21,6 +21,7 @@ const VSCODE_DIRNAME: &str = ".vscode"; const LAST_RUN_DIRNAME: &str = "last_run"; const PYPROJECT_FILENAME: &str = "pyproject.toml"; const USE_CASE_FILENAME: &str = "use_case.toml"; +const PROJECT_CONFIG_FILENAME: &str = "config.toml"; const VSCODE_SETTINGS_FILENAME: &str = "settings.json"; pub async fn config_dir() -> Result { @@ -82,6 +83,10 @@ pub fn project_use_case_toml_path(project_dir: impl AsRef) -> PathBuf { project_data_dir(project_dir, USE_CASE_FILENAME) } +pub fn project_config_file_path(project_dir: impl AsRef) -> PathBuf { + project_config_dir(project_dir).join(PROJECT_CONFIG_FILENAME) +} + pub fn project_vscode_dir(project_dir: impl AsRef) -> PathBuf { project_dir.as_ref().join(VSCODE_DIRNAME) } diff --git a/src/graphql/get_competition_use_case.graphql b/src/graphql/get_competition_use_case.graphql index fa4df03..ea65b69 100644 --- a/src/graphql/get_competition_use_case.graphql +++ b/src/graphql/get_competition_use_case.graphql @@ -1,6 +1,7 @@ query GetCompetitionUseCase($slug: String!) { competitionBySlug(slug: $slug) { id + hasLeaderboard useCase { name latest { diff --git a/src/graphql/schema.graphql b/src/graphql/schema.graphql index 529a8d5..e4022fc 100644 --- a/src/graphql/schema.graphql +++ b/src/graphql/schema.graphql @@ -44,6 +44,7 @@ enum Action { READ_USER_EMAIL READ_USER_NOTIFICATIONS READ_USER_PERMISSIONS + READ_ACTIVITY_TRACKER REMOVE_COMPETITION_MEMBER REMOVE_EVENT_COMPETITION REMOVE_EVENT_MEMBER @@ -66,6 +67,56 @@ enum Action { UPLOAD_FILES } +type Activity { + date: NaiveDate! + points: Int! + level: Int! +} + +type ActivityConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [ActivityEdge!]! + """ + A list of nodes. + """ + nodes: [Activity!]! +} + +""" +An edge in a connection. +""" +type ActivityEdge { + """ + The item at the end of the edge + """ + node: Activity! + """ + A cursor for use in pagination + """ + cursor: String! +} + +enum ActivityVisibility { + """ + Activity is only visible to invited members. + """ + MEMBERS + """ + Activity is visible by everyone, even unauthenticated users. + """ + UNAUTHENTICATED + """ + Activity is visible by every authenticated user. + """ + AUTHENTICATED +} + enum ArchiveKind { TAR ZIP @@ -187,10 +238,13 @@ type Competition implements ForumOwner & Node { title: String! shortDescription: String! createdAt: DateTime! - isPrivate: Boolean! + visibility: ActivityVisibility! requiresApproval: Boolean! + leaderboardSize: Int! + hasLeaderboard: Boolean! id: ID! description: String + isPrivate: Boolean! viewerCan(action: Action!, asEntity: UsernameOrID): Boolean! host: Entity! useCase: UseCase! @@ -204,7 +258,7 @@ type Competition implements ForumOwner & Node { submission(entity: UsernameOrID): Submission forum: Forum! members(after: String, before: String, first: Int, last: Int): CompetitionMembershipConnection! - tags(after: String, before: String, first: Int, last: Int): TagConnection! + tags(after: String, before: String, first: Int, last: Int): CompetitionTagConnection! } type CompetitionConnection { @@ -351,6 +405,21 @@ type CompetitionRuleEdge { cursor: String! } +type CompetitionTagConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [TagEdge!]! + """ + A list of nodes. + """ + nodes: [Tag!]! +} + input CreateCommentInput { content: String! } @@ -362,9 +431,10 @@ input CreateCompetitionInput { description: String banner: Upload thumbnail: Upload - isPrivate: Boolean! + visibility: ActivityVisibility! = UNAUTHENTICATED requiresApproval: Boolean tagIds: [ID] + hasLeaderboard: Boolean } input CreateEventInput { @@ -374,7 +444,7 @@ input CreateEventInput { description: String banner: Upload thumbnail: Upload - isPrivate: Boolean! + visibility: ActivityVisibility! = UNAUTHENTICATED } input CreateForumInput { @@ -448,6 +518,14 @@ interface Entity { badges(after: String, before: String, first: Int, last: Int): EntityBadgeConnection! subjectSubscriptions(kinds: [SubjectKind!], after: String, before: String, first: Int, last: Int): SubjectSubscriptionConnection! projectVersionApprovals(projectVersionId: UUID, after: String, before: String, first: Int, last: Int): ProjectVersionApprovalConnection! + points: Int! + rank: Int! +} + +enum EntityActivitiesConnectionKind { + SUBMISSION + TOPIC + COMMENT } type EntityBadge implements Node { @@ -530,9 +608,10 @@ type Event implements ForumOwner & Node { title: String! shortDescription: String! createdAt: DateTime! - isPrivate: Boolean! + visibility: ActivityVisibility! id: ID! description: String + isPrivate: Boolean! agenda: JSON viewerCan(action: Action!, asEntity: UsernameOrID): Boolean! host: Entity! @@ -786,6 +865,37 @@ type ForumSubscription implements SubjectSubscription & Node { subject: Subscribable! } +type GlobalLeaderboardConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [GlobalLeaderboardEdge!]! + """ + A list of nodes. + """ + nodes: [Entity!]! +} + +""" +An edge in a connection. +""" +type GlobalLeaderboardEdge { + """ + The item at the end of the edge + """ + node: Entity! + rank: Int! + points: Int! + """ + A cursor for use in pagination + """ + cursor: String! +} + type InitUploadFile { key: String! @@ -877,6 +987,17 @@ type Mutation { fetchWebsiteMetadata(url: Url!): WebsiteMetadata! } +""" +ISO 8601 calendar date without timezone. +Format: %Y-%m-%d + +# Examples + +* `1994-11-13` +* `2000-02-24` +""" +scalar NaiveDate + interface Node { id: ID! } @@ -887,6 +1008,7 @@ enum NotificationKind { CREATE_TOPIC REPLY_TOPIC CONTENT_MENTIONED + REPLY_COMMENT SYSTEM } @@ -939,13 +1061,15 @@ type Organization implements Entity & Node { kind: EntityKind! image: Url imageThumbnail: Url - users(after: String, before: String, first: Int, last: Int): OrganizationMembershipConnection! + users(after: String, before: String, first: Int, last: Int): OrganizationUserConnection! submissions(after: String, before: String, first: Int, last: Int, competitionId: ID): SubmissionConnection! viewerCan(action: Action!, asEntity: UsernameOrID): Boolean! badges(after: String, before: String, first: Int, last: Int): EntityBadgeConnection! userMembership(user: UsernameOrID): OrganizationMembership subjectSubscriptions(kinds: [SubjectKind!], after: String, before: String, first: Int, last: Int): SubjectSubscriptionConnection! projectVersionApprovals(projectVersionId: UUID, after: String, before: String, first: Int, last: Int): ProjectVersionApprovalConnection! + rank: Int! + points: Int! } """ @@ -970,21 +1094,6 @@ type OrganizationMembership implements Node { viewerCan(action: Action!, asEntity: UsernameOrID): Boolean! } -type OrganizationMembershipConnection { - """ - Information to aid in pagination. - """ - pageInfo: PageInfo! - """ - A list of edges. - """ - edges: [OrganizationMembershipEdge!]! - """ - A list of nodes. - """ - nodes: [OrganizationMembership!]! -} - """ An edge in a connection. """ @@ -1006,6 +1115,21 @@ enum OrganizationMembershipKind { READER } +type OrganizationUserConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [OrganizationMembershipEdge!]! + """ + A list of nodes. + """ + nodes: [OrganizationMembership!]! +} + """ Information about pagination in a connection """ @@ -1142,10 +1266,12 @@ type ProjectVersionEvaluation implements Node { max: Boolean! finalizedAt: DateTime createdAt: DateTime! + rank: Int id: ID! viewerCan(action: Action!, asEntity: UsernameOrID): Boolean! projectVersion: ProjectVersion! submission: Submission! + points: Int! } type ProjectVersionEvaluationConnection { @@ -1210,6 +1336,7 @@ type Query { competitionBySlug(slug: String!): Competition entities(after: String, before: String, first: Int, last: Int, search: String, kinds: [EntityKind!]): EntityConnection! entityByUsername(username: String!): Entity + leaderboard(after: String, before: String, first: Int, last: Int, search: String, kinds: [EntityKind!]): GlobalLeaderboardConnection! events(after: String, before: String, first: Int, last: Int, search: String): EventConnection! eventBySlug(slug: String!): Event version: Version! @@ -1334,6 +1461,7 @@ type Subscription { deletedComments(topicId: ID): DeletedComment! updatedComments(topicId: ID): CommentEdge! projectVersionStatusUpdate(competitionId: ID, entityId: ID, projectId: ID, projectVersionId: ID): ProjectVersion! + updatedEntity(id: ID): Entity! } type Tag implements Node { @@ -1455,8 +1583,9 @@ input UpdateCompetitionInput { banner: Upload thumbnail: Upload rules: String - isPrivate: Boolean + visibility: ActivityVisibility requiresApproval: Boolean + hasLeaderboard: Boolean tagIds: [ID] } @@ -1467,7 +1596,7 @@ input UpdateEventInput { description: String banner: Upload thumbnail: Upload - isPrivate: Boolean + visibility: ActivityVisibility } input UpdateForumInput { @@ -1489,6 +1618,7 @@ input UpdateOrganizationInput { location: String bio: String image: Upload + inQuantumJob: Boolean } input UpdateSubmissionInput { @@ -1517,6 +1647,7 @@ input UpdateUserInput { location: String bio: String image: Upload + inQuantumJob: Boolean password: String oldPassword: String email: String @@ -1549,6 +1680,7 @@ type User implements Entity & Node { github: String website: String bio: String + isAvailableOnQuantumJobs: Boolean! createdAt: DateTime! id: ID! kind: EntityKind! @@ -1561,17 +1693,21 @@ type User implements Entity & Node { can this user perform the action on the given resource """ can(action: Action!, on: ID, actingAs: UsernameOrID): Boolean! - organizations(after: String, before: String, first: Int, last: Int): OrganizationMembershipConnection! + organizations(after: String, before: String, first: Int, last: Int): UserOrganizationConnection! submissions(after: String, before: String, first: Int, last: Int, competitionId: ID): SubmissionConnection! topics(after: String, before: String, first: Int, last: Int, order: VotableOrder): TopicConnection! comments(after: String, before: String, first: Int, last: Int, order: VotableOrder): CommentConnection! viewerCan(action: Action!, asEntity: UsernameOrID): Boolean! badges(after: String, before: String, first: Int, last: Int): EntityBadgeConnection! - entities(permission: Permission, search: String, after: String, before: String, first: Int, last: Int): EntityConnection! + entities(permission: Permission, search: String, after: String, before: String, first: Int, last: Int): UserEntitiesConnection! notifications: UserNotifications! subjectSubscriptions(kinds: [SubjectKind!], after: String, before: String, first: Int, last: Int): SubjectSubscriptionConnection! projectVersionApprovals(projectVersionId: UUID, after: String, before: String, first: Int, last: Int): ProjectVersionApprovalConnection! karma: Int! + jobBoardProfileLink: Url + rank: Int! + points: Int! + activities(after: String, before: String, first: Int, last: Int, kinds: [EntityActivitiesConnectionKind!]): ActivityConnection! } """ @@ -1588,11 +1724,41 @@ type UserEdge { cursor: String! } +type UserEntitiesConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [EntityEdge!]! + """ + A list of nodes. + """ + nodes: [Entity!]! +} + type UserNotifications { enabled: [NotificationKind!]! disabled: [NotificationKind!]! } +type UserOrganizationConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [OrganizationMembershipEdge!]! + """ + A list of nodes. + """ + nodes: [OrganizationMembership!]! +} + scalar UsernameOrID type Version { diff --git a/src/lib.rs b/src/lib.rs index 71bb76c..546bfb0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod cfg_file; mod colors; mod commands; mod compress; +mod config; mod credentials; mod dirs; mod download;