Compare commits
	
		
			23 Commits
		
	
	
		
			8b2f0102c4
			...
			5f2120ea18
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5f2120ea18 | |||
| b7102e12a6 | |||
| 479389dde6 | |||
| 7759632848 | |||
| 62657f2bdd | |||
| 889859dbae | |||
| 73b3e2cb96 | |||
| ce60eaf310 | |||
| 573d7f8635 | |||
| b7da62536d | |||
| 0f3c031b7a | |||
| 84b4c747f5 | |||
| b722b21a75 | |||
| 90457155aa | |||
| 9cfe56b3ce | |||
| 991e7fd746 | |||
| b9b211723e | |||
| 43399c28b1 | |||
| ac049beff7 | |||
| f08397ab15 | |||
| ee21753411 | |||
| 1df76ea573 | |||
| 82d9cf5c87 | 
							
								
								
									
										3
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								.env
									
									
									
									
									
								
							| @ -1 +1,4 @@ | |||||||
| DATABASE_URL=postgres://postgres@localhost:5432/toggl_portal | DATABASE_URL=postgres://postgres@localhost:5432/toggl_portal | ||||||
|  | TOGGL_API_TOKEN=237918c4e008f5aeefe886c9112ab560 | ||||||
|  | TOGGL_WORKSPACE_ID=2837131 | ||||||
|  | WORKSPACE_ID=${TOGGL_WORKSPACE_ID} | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							| @ -30,7 +30,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           context: . |           context: . | ||||||
|           push: true |           push: true | ||||||
|           tags: git.joshuacoles.me/personal/beachhead-services/toggl-portal:arm |           tags: git.joshuacoles.me/personal/toggl-portal:arm | ||||||
|           build-args: | |           build-args: | | ||||||
|             APP_NAME=toggl-portal |             APP_NAME=toggl-portal | ||||||
|             PACKAGE_NAME=toggl-portal |             PACKAGE_NAME=toggl-portal | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,3 +1,4 @@ | |||||||
| /target | /target | ||||||
|  | /migration/target | ||||||
| /.idea | /.idea | ||||||
| /ignore | /ignore | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -1630,6 +1630,7 @@ version = "0.1.0" | |||||||
| dependencies = [ | dependencies = [ | ||||||
|  "async-std", |  "async-std", | ||||||
|  "sea-orm-migration", |  "sea-orm-migration", | ||||||
|  |  "serde_json", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								GRAFTING.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								GRAFTING.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | - Possible sources | ||||||
|  |   - report | ||||||
|  |   - me/time_enries | ||||||
|  |     - This will need to filter by `workspace_id` if don't want to deal with multiple workspaces. | ||||||
|  |   - csv | ||||||
|  |     - This can give us a complete picture of the time entries, but it lacks an `id` field so cannot be easily updated | ||||||
|  | - The first two contain `at` which is when the time entry was last updated, useful for grafting | ||||||
|  | - `me/time_entries` can get anything **updated** since a given time | ||||||
|  |   - This is incredibly useful for updating the time entries | ||||||
|  | - Most historic time entries are not ever changed so a csv of say, 2023 is probably alwasy going to be valid | ||||||
							
								
								
									
										5
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | # Toggl Portal | ||||||
|  | 
 | ||||||
|  | ## Purpose | ||||||
|  | - Act as an authenticated client for other apps to use to access Toggl data in addition to caching this data for other | ||||||
|  |   data analysis purposes. | ||||||
							
								
								
									
										2811
									
								
								migration/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2811
									
								
								migration/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -10,10 +10,12 @@ path = "src/lib.rs" | |||||||
| 
 | 
 | ||||||
| [dependencies] | [dependencies] | ||||||
| async-std = { version = "1", features = ["attributes", "tokio1"] } | async-std = { version = "1", features = ["attributes", "tokio1"] } | ||||||
|  | serde_json = "^1.0" | ||||||
| 
 | 
 | ||||||
| [dependencies.sea-orm-migration] | [dependencies.sea-orm-migration] | ||||||
| version = "0.12.0" | version = "0.12.0" | ||||||
| features = [ | features = [ | ||||||
|     "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature |     "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature | ||||||
|     "sqlx-postgres", # `DATABASE_DRIVER` feature |     "sqlx-postgres", # `DATABASE_DRIVER` feature | ||||||
|  |     'with-json' | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -4,6 +4,8 @@ mod m20231101_172500_create_time_entry_table; | |||||||
| mod m20231106_134950_create_clients; | mod m20231106_134950_create_clients; | ||||||
| mod m20231106_195401_create_projects; | mod m20231106_195401_create_projects; | ||||||
| mod m20231106_201029_add_time_entry_project_fk; | mod m20231106_201029_add_time_entry_project_fk; | ||||||
|  | mod m20240302_102418_update_project_table; | ||||||
|  | mod m20240302_171651_update_time_entry_table; | ||||||
| 
 | 
 | ||||||
| pub struct Migrator; | pub struct Migrator; | ||||||
| 
 | 
 | ||||||
| @ -15,6 +17,8 @@ impl MigratorTrait for Migrator { | |||||||
|             Box::new(m20231106_134950_create_clients::Migration), |             Box::new(m20231106_134950_create_clients::Migration), | ||||||
|             Box::new(m20231106_195401_create_projects::Migration), |             Box::new(m20231106_195401_create_projects::Migration), | ||||||
|             Box::new(m20231106_201029_add_time_entry_project_fk::Migration), |             Box::new(m20231106_201029_add_time_entry_project_fk::Migration), | ||||||
|  |             Box::new(m20240302_102418_update_project_table::Migration), | ||||||
|  |             Box::new(m20240302_171651_update_time_entry_table::Migration), | ||||||
|         ] |         ] | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -19,11 +19,23 @@ impl MigrationTrait for Migration { | |||||||
|                             .primary_key(), |                             .primary_key(), | ||||||
|                     ) |                     ) | ||||||
|                     .col( |                     .col( | ||||||
|                         ColumnDef::new(TimeEntry::TogglId).big_unsigned().not_null().unique_key()) |                         ColumnDef::new(TimeEntry::TogglId) | ||||||
|  |                             .big_unsigned() | ||||||
|  |                             .not_null() | ||||||
|  |                             .unique_key(), | ||||||
|  |                     ) | ||||||
|                     .col(ColumnDef::new(TimeEntry::Description).string().not_null()) |                     .col(ColumnDef::new(TimeEntry::Description).string().not_null()) | ||||||
|                     .col(ColumnDef::new(TimeEntry::ProjectId).big_unsigned()) |                     .col(ColumnDef::new(TimeEntry::ProjectId).big_unsigned()) | ||||||
|                     .col(ColumnDef::new(TimeEntry::Start).timestamp_with_time_zone().not_null()) |                     .col( | ||||||
|                     .col(ColumnDef::new(TimeEntry::Stop).timestamp_with_time_zone().not_null()) |                         ColumnDef::new(TimeEntry::Start) | ||||||
|  |                             .timestamp_with_time_zone() | ||||||
|  |                             .not_null(), | ||||||
|  |                     ) | ||||||
|  |                     .col( | ||||||
|  |                         ColumnDef::new(TimeEntry::Stop) | ||||||
|  |                             .timestamp_with_time_zone() | ||||||
|  |                             .not_null(), | ||||||
|  |                     ) | ||||||
|                     .col(ColumnDef::new(TimeEntry::RawJson).json_binary().not_null()) |                     .col(ColumnDef::new(TimeEntry::RawJson).json_binary().not_null()) | ||||||
|                     .to_owned(), |                     .to_owned(), | ||||||
|             ) |             ) | ||||||
| @ -46,5 +58,5 @@ enum TimeEntry { | |||||||
|     ProjectId, |     ProjectId, | ||||||
|     Start, |     Start, | ||||||
|     Stop, |     Stop, | ||||||
|     RawJson |     RawJson, | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,7 +20,11 @@ impl MigrationTrait for Migration { | |||||||
|                     .col(ColumnDef::new(Client::Name).string().not_null()) |                     .col(ColumnDef::new(Client::Name).string().not_null()) | ||||||
|                     .col(ColumnDef::new(Client::Archived).boolean().not_null()) |                     .col(ColumnDef::new(Client::Archived).boolean().not_null()) | ||||||
|                     .col(ColumnDef::new(Client::WorkspaceId).integer().not_null()) |                     .col(ColumnDef::new(Client::WorkspaceId).integer().not_null()) | ||||||
|                     .col(ColumnDef::new(Client::At).timestamp_with_time_zone().not_null()) |                     .col( | ||||||
|  |                         ColumnDef::new(Client::At) | ||||||
|  |                             .timestamp_with_time_zone() | ||||||
|  |                             .not_null(), | ||||||
|  |                     ) | ||||||
|                     .col(ColumnDef::new(Client::ServerDeletedAt).timestamp_with_time_zone()) |                     .col(ColumnDef::new(Client::ServerDeletedAt).timestamp_with_time_zone()) | ||||||
|                     .to_owned(), |                     .to_owned(), | ||||||
|             ) |             ) | ||||||
| @ -42,5 +46,5 @@ enum Client { | |||||||
|     Archived, |     Archived, | ||||||
|     WorkspaceId, |     WorkspaceId, | ||||||
|     At, |     At, | ||||||
|     ServerDeletedAt |     ServerDeletedAt, | ||||||
| } | } | ||||||
|  | |||||||
| @ -11,8 +11,19 @@ impl MigrationTrait for Migration { | |||||||
|                 Table::create() |                 Table::create() | ||||||
|                     .table(Project::Table) |                     .table(Project::Table) | ||||||
|                     .if_not_exists() |                     .if_not_exists() | ||||||
|                     .col(ColumnDef::new(Project::Id).integer().primary_key().auto_increment().not_null()) |                     .col( | ||||||
|                     .col(ColumnDef::new(Project::TogglId).big_unsigned().not_null().unique_key()) |                         ColumnDef::new(Project::Id) | ||||||
|  |                             .integer() | ||||||
|  |                             .primary_key() | ||||||
|  |                             .auto_increment() | ||||||
|  |                             .not_null(), | ||||||
|  |                     ) | ||||||
|  |                     .col( | ||||||
|  |                         ColumnDef::new(Project::TogglId) | ||||||
|  |                             .big_unsigned() | ||||||
|  |                             .not_null() | ||||||
|  |                             .unique_key(), | ||||||
|  |                     ) | ||||||
|                     .col( |                     .col( | ||||||
|                         ColumnDef::new(Project::WorkspaceId) |                         ColumnDef::new(Project::WorkspaceId) | ||||||
|                             .big_unsigned() |                             .big_unsigned() | ||||||
| @ -27,13 +38,15 @@ impl MigrationTrait for Migration { | |||||||
|             .await?; |             .await?; | ||||||
| 
 | 
 | ||||||
|         // Create foreign key
 |         // Create foreign key
 | ||||||
|         manager.create_foreign_key( |         manager | ||||||
|  |             .create_foreign_key( | ||||||
|                 ForeignKey::create() |                 ForeignKey::create() | ||||||
|                     .name("project_client_id") |                     .name("project_client_id") | ||||||
|                     .from(Project::Table, Project::ClientId) |                     .from(Project::Table, Project::ClientId) | ||||||
|                     .to(Client::Table, Client::Id) |                     .to(Client::Table, Client::Id) | ||||||
|                     .to_owned(), |                     .to_owned(), | ||||||
|         ).await?; |             ) | ||||||
|  |             .await?; | ||||||
| 
 | 
 | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  | |||||||
							
								
								
									
										69
									
								
								migration/src/m20240302_102418_update_project_table.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								migration/src/m20240302_102418_update_project_table.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,69 @@ | |||||||
|  | use sea_orm_migration::prelude::*; | ||||||
|  | 
 | ||||||
|  | #[derive(DeriveMigrationName)] | ||||||
|  | pub struct Migration; | ||||||
|  | 
 | ||||||
|  | #[async_trait::async_trait] | ||||||
|  | impl MigrationTrait for Migration { | ||||||
|  |     async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | ||||||
|  |         manager | ||||||
|  |             .alter_table( | ||||||
|  |                 TableAlterStatement::new() | ||||||
|  |                     .table(Project::Table) | ||||||
|  |                     .add_column(ColumnDef::new(Project::Color).text()) | ||||||
|  |                     .add_column(ColumnDef::new(Project::ServerCreatedAt).timestamp_with_time_zone()) | ||||||
|  |                     .add_column(ColumnDef::new(Project::ServerUpdatedAt).timestamp_with_time_zone()) | ||||||
|  |                     .add_column(ColumnDef::new(Project::ServerDeletedAt).timestamp_with_time_zone()) | ||||||
|  |                     .to_owned(), | ||||||
|  |             ) | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         manager | ||||||
|  |             .get_connection() | ||||||
|  |             .execute_unprepared( | ||||||
|  |                 r#" | ||||||
|  |                 update "project" | ||||||
|  |                 set "color" = raw_json ->> 'color', | ||||||
|  |                     "server_created_at" = (raw_json ->> 'created_at') :: timestamptz, | ||||||
|  |                     "server_updated_at" = (raw_json ->> 'at') :: timestamptz, | ||||||
|  |                     "server_deleted_at" = (raw_json ->> 'server_deleted_at') :: timestamptz | ||||||
|  |             "#,
 | ||||||
|  |             ) | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         manager | ||||||
|  |             .alter_table( | ||||||
|  |                 TableAlterStatement::new() | ||||||
|  |                     .table(Project::Table) | ||||||
|  |                     .modify_column(ColumnDef::new(Project::Color).not_null()) | ||||||
|  |                     .modify_column(ColumnDef::new(Project::ServerCreatedAt).not_null()) | ||||||
|  |                     .modify_column(ColumnDef::new(Project::ServerUpdatedAt).not_null()) | ||||||
|  |                     .to_owned(), | ||||||
|  |             ) | ||||||
|  |             .await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | ||||||
|  |         manager | ||||||
|  |             .alter_table( | ||||||
|  |                 TableAlterStatement::new() | ||||||
|  |                     .table(Project::Table) | ||||||
|  |                     .drop_column(Project::Color) | ||||||
|  |                     .drop_column(Project::ServerCreatedAt) | ||||||
|  |                     .drop_column(Project::ServerUpdatedAt) | ||||||
|  |                     .drop_column(Project::ServerDeletedAt) | ||||||
|  |                     .to_owned(), | ||||||
|  |             ) | ||||||
|  |             .await | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(DeriveIden)] | ||||||
|  | enum Project { | ||||||
|  |     Table, | ||||||
|  |     Color, | ||||||
|  | 
 | ||||||
|  |     ServerCreatedAt, | ||||||
|  |     ServerUpdatedAt, | ||||||
|  |     ServerDeletedAt, | ||||||
|  | } | ||||||
							
								
								
									
										70
									
								
								migration/src/m20240302_171651_update_time_entry_table.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								migration/src/m20240302_171651_update_time_entry_table.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,70 @@ | |||||||
|  | use sea_orm_migration::prelude::*; | ||||||
|  | 
 | ||||||
|  | #[derive(DeriveMigrationName)] | ||||||
|  | pub struct Migration; | ||||||
|  | 
 | ||||||
|  | #[async_trait::async_trait] | ||||||
|  | impl MigrationTrait for Migration { | ||||||
|  |     async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | ||||||
|  |         manager | ||||||
|  |             .alter_table( | ||||||
|  |                 TableAlterStatement::new() | ||||||
|  |                     .table(TimeEntry::Table) | ||||||
|  |                     .add_column( | ||||||
|  |                         ColumnDef::new(TimeEntry::Tags) | ||||||
|  |                             .json_binary() | ||||||
|  |                             .default(serde_json::json!([])) | ||||||
|  |                             .not_null(), | ||||||
|  |                     ) | ||||||
|  |                     .add_column( | ||||||
|  |                         ColumnDef::new(TimeEntry::ServerUpdatedAt).timestamp_with_time_zone(), | ||||||
|  |                     ) | ||||||
|  |                     .add_column( | ||||||
|  |                         ColumnDef::new(TimeEntry::ServerDeletedAt).timestamp_with_time_zone(), | ||||||
|  |                     ) | ||||||
|  |                     .to_owned(), | ||||||
|  |             ) | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         manager | ||||||
|  |             .get_connection() | ||||||
|  |             .execute_unprepared( | ||||||
|  |                 r#" | ||||||
|  |                 update "time_entry" | ||||||
|  |                 set "tags" = coalesce(raw_json -> 'tags', '[]' :: jsonb), | ||||||
|  |                     "server_updated_at" = (raw_json ->> 'at') :: timestamptz; | ||||||
|  |             "#,
 | ||||||
|  |             ) | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         manager | ||||||
|  |             .alter_table( | ||||||
|  |                 TableAlterStatement::new() | ||||||
|  |                     .table(TimeEntry::Table) | ||||||
|  |                     .modify_column(ColumnDef::new(TimeEntry::ServerUpdatedAt).not_null()) | ||||||
|  |                     .to_owned(), | ||||||
|  |             ) | ||||||
|  |             .await | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | ||||||
|  |         manager | ||||||
|  |             .alter_table( | ||||||
|  |                 TableAlterStatement::new() | ||||||
|  |                     .table(TimeEntry::Table) | ||||||
|  |                     .drop_column(TimeEntry::Tags) | ||||||
|  |                     .drop_column(TimeEntry::ServerDeletedAt) | ||||||
|  |                     .drop_column(TimeEntry::ServerUpdatedAt) | ||||||
|  |                     .to_owned(), | ||||||
|  |             ) | ||||||
|  |             .await | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(DeriveIden)] | ||||||
|  | enum TimeEntry { | ||||||
|  |     Table, | ||||||
|  |     ServerUpdatedAt, | ||||||
|  |     ServerDeletedAt, | ||||||
|  |     Tags, | ||||||
|  | } | ||||||
							
								
								
									
										178
									
								
								src/client.rs
									
									
									
									
									
								
							
							
						
						
									
										178
									
								
								src/client.rs
									
									
									
									
									
								
							| @ -1,178 +0,0 @@ | |||||||
| use reqwest::Client; |  | ||||||
| use serde_json::Value; |  | ||||||
| use std::collections::HashMap; |  | ||||||
| use std::time::Duration; |  | ||||||
| use hyper::HeaderMap; |  | ||||||
| use tracing::instrument; |  | ||||||
| use tracing::log::debug; |  | ||||||
| use crate::types::{Current, Project, ProjectClient, ReportEntry, TogglQuery}; |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Clone)] |  | ||||||
| pub struct TogglClient { |  | ||||||
|     client: Client, |  | ||||||
|     workspace_id: String, |  | ||||||
|     base_url: String, |  | ||||||
|     reports_base_url: String, |  | ||||||
| 
 |  | ||||||
|     headers: HeaderMap, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| impl TogglClient { |  | ||||||
|     pub async fn check_health(&self) -> bool { |  | ||||||
|         true |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn new(workspace_id: &str, toggl_auth: &str) -> Self { |  | ||||||
|         let client = Client::builder() |  | ||||||
|             .default_headers(Self::default_headers(toggl_auth)) |  | ||||||
|             .build() |  | ||||||
|             .expect("Failed to build reqwest client"); |  | ||||||
| 
 |  | ||||||
|         Self { |  | ||||||
|             client, |  | ||||||
|             workspace_id: workspace_id.to_string(), |  | ||||||
|             base_url: "https://api.track.toggl.com/api/v9".to_string(), |  | ||||||
|             reports_base_url: "https://api.track.toggl.com/reports/api/v3".to_string(), |  | ||||||
|             headers: Self::default_headers(toggl_auth), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     fn default_headers(toggl_auth: &str) -> reqwest::header::HeaderMap { |  | ||||||
|         let mut headers = reqwest::header::HeaderMap::new(); |  | ||||||
|         headers.insert( |  | ||||||
|             "Authorization", |  | ||||||
|             reqwest::header::HeaderValue::from_str(&format!("Basic {}", toggl_auth)).unwrap(), |  | ||||||
|         ); |  | ||||||
|         headers |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn fetch_projects(&self) -> Result<Vec<Project>, reqwest::Error> { |  | ||||||
|         let url = format!( |  | ||||||
|             "{base_url}/workspaces/{}/projects", |  | ||||||
|             self.workspace_id, |  | ||||||
|             base_url = self.base_url, |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         let res = self |  | ||||||
|             .client |  | ||||||
|             .get(&url) |  | ||||||
|             .headers(self.headers.clone()) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .json::<Vec<Project>>() |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         Ok(res) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn fetch_clients(&self) -> Result<Vec<ProjectClient>, reqwest::Error> { |  | ||||||
|         let url = format!( |  | ||||||
|             "{base_url}/workspaces/{}/clients", |  | ||||||
|             self.workspace_id, |  | ||||||
|             base_url = self.base_url, |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         let res = self |  | ||||||
|             .client |  | ||||||
|             .get(&url) |  | ||||||
|             .headers(self.headers.clone()) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .json::<Vec<ProjectClient>>() |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         Ok(res) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn get_current(&self) -> Result<Option<Current>, reqwest::Error> { |  | ||||||
|         let url = format!( |  | ||||||
|             "{base_url}/me/time_entries/current", |  | ||||||
|             base_url = self.base_url |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         let res = self |  | ||||||
|             .client |  | ||||||
|             .get(url) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .json::<Option<Current>>() |  | ||||||
|             .await |  | ||||||
|             .unwrap(); |  | ||||||
| 
 |  | ||||||
|         Ok(res) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     fn create_filters(original_filters: &TogglQuery, last_row_id: u64) -> TogglQuery { |  | ||||||
|         let mut filters: TogglQuery = original_filters.clone(); |  | ||||||
|         filters.first_row_number = Some(last_row_id + 1); |  | ||||||
|         filters |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[instrument(skip(self, filters))] |  | ||||||
|     pub async fn full_report( |  | ||||||
|         &self, |  | ||||||
|         filters: &TogglQuery, |  | ||||||
|     ) -> anyhow::Result<Vec<ReportEntry>> { |  | ||||||
|         let url = format!( |  | ||||||
|             "{base_url}/workspace/{workspace_id}/search/time_entries", |  | ||||||
|             base_url = self.reports_base_url, |  | ||||||
|             workspace_id = self.workspace_id |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         let mut last_row_number = Some(0); |  | ||||||
|         let mut results = vec![]; |  | ||||||
| 
 |  | ||||||
|         while let Some(last_row_number_n) = last_row_number { |  | ||||||
|             debug!("Fetching page starting with {}", last_row_number_n); |  | ||||||
|             // If we are not on the first page, wait a bit to avoid rate limiting
 |  | ||||||
|             if last_row_number_n != 0 { |  | ||||||
|                 tokio::time::sleep(Duration::from_millis(1000)).await; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             // TODO: Implement rate limiting
 |  | ||||||
|             let response = self |  | ||||||
|                 .client |  | ||||||
|                 .post(&url) |  | ||||||
|                 .headers(self.headers.clone()) |  | ||||||
|                 .json(&Self::create_filters(&filters, last_row_number_n)) |  | ||||||
|                 .send() |  | ||||||
|                 .await?; |  | ||||||
| 
 |  | ||||||
|             let data = response |  | ||||||
|                 .json::<Vec<ReportEntry>>() |  | ||||||
|                 .await?; |  | ||||||
| 
 |  | ||||||
|             last_row_number = data.last().map(|e| e.row_number as u64); |  | ||||||
| 
 |  | ||||||
|             data.into_iter().for_each(|e| results.push(e)); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         Ok(results) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub async fn start_time_entry(&self, mut body: HashMap<String, Value>) -> anyhow::Result<()> { |  | ||||||
|         let url = format!( |  | ||||||
|             "{base_url}/workspaces/{workspace_id}/time_entries", |  | ||||||
|             base_url = self.base_url, |  | ||||||
|             workspace_id = self.workspace_id |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         body.insert( |  | ||||||
|             "workspace_id".to_string(), |  | ||||||
|             self.workspace_id.parse::<i32>().unwrap().into(), |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         dbg!(self.client |  | ||||||
|             .post(url) |  | ||||||
|             .headers(self.headers.clone()) |  | ||||||
|             .json(&body) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .text() |  | ||||||
|             .await?); |  | ||||||
| 
 |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										69
									
								
								src/csv_parser.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/csv_parser.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,69 @@ | |||||||
|  | use crate::utils::Result; | ||||||
|  | use anyhow::anyhow; | ||||||
|  | use chrono::{NaiveDate, NaiveTime}; | ||||||
|  | use csv::StringRecord; | ||||||
|  | 
 | ||||||
|  | mod headings { | ||||||
|  |     pub const USER: usize = 1; | ||||||
|  |     pub const USER_EMAIL: usize = 2; | ||||||
|  |     pub const CLIENT_NAME: usize = 3; | ||||||
|  |     pub const PROJECT_NAME: usize = 4; | ||||||
|  |     pub const TASK_NAME: usize = 5; | ||||||
|  |     pub const DESCRIPTION: usize = 6; | ||||||
|  |     pub const BILLABLE: usize = 7; | ||||||
|  |     pub const START_DATE: usize = 8; | ||||||
|  |     pub const END_DATE: usize = 9; | ||||||
|  |     pub const START_TIME: usize = 10; | ||||||
|  |     pub const END_TIME: usize = 11; | ||||||
|  |     pub const DURATION: usize = 12; | ||||||
|  |     pub const TAGS: usize = 13; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | fn parse_csv_row(row: StringRecord) -> Result<crate::entity::time_entry::Model> { | ||||||
|  |     let start_date = row | ||||||
|  |         .get(headings::START_DATE) | ||||||
|  |         .ok_or(anyhow!("Missing start date in CSV"))?; | ||||||
|  |     let start_time = row | ||||||
|  |         .get(headings::START_TIME) | ||||||
|  |         .ok_or(anyhow!("Missing start time in CSV"))?; | ||||||
|  |     let end_date = row | ||||||
|  |         .get(headings::END_DATE) | ||||||
|  |         .ok_or(anyhow!("Missing end date in CSV"))?; | ||||||
|  |     let end_time = row | ||||||
|  |         .get(headings::END_TIME) | ||||||
|  |         .ok_or(anyhow!("Missing end time in CSV"))?; | ||||||
|  | 
 | ||||||
|  |     let start_time = NaiveTime::parse_from_str(start_time, "%H:%M:%S")?; | ||||||
|  |     let end_time = NaiveTime::parse_from_str(end_time, "%H:%M:%S")?; | ||||||
|  |     let start_date = NaiveDate::parse_from_str(start_date, "%Y-%m-%d")?; | ||||||
|  |     let end_date = NaiveDate::parse_from_str(end_date, "%Y-%m-%d")?; | ||||||
|  | 
 | ||||||
|  |     let start = start_date.and_time(start_time); | ||||||
|  |     let end = end_date.and_time(end_time); | ||||||
|  | 
 | ||||||
|  |     let description = row | ||||||
|  |         .get(headings::DESCRIPTION) | ||||||
|  |         .ok_or(anyhow!("Missing description in CSV"))?; | ||||||
|  |     let project_name = row | ||||||
|  |         .get(headings::PROJECT_NAME) | ||||||
|  |         .ok_or(anyhow!("Missing project name in CSV"))?; | ||||||
|  |     let client_name = row | ||||||
|  |         .get(headings::CLIENT_NAME) | ||||||
|  |         .ok_or(anyhow!("Missing client name in CSV"))?; | ||||||
|  |     let tags = row | ||||||
|  |         .get(headings::TAGS) | ||||||
|  |         .ok_or(anyhow!("Missing tags in CSV"))?; | ||||||
|  |     let task_name = row | ||||||
|  |         .get(headings::TASK_NAME) | ||||||
|  |         .ok_or(anyhow!("Missing task name in CSV"))?; | ||||||
|  |     let billable = match row | ||||||
|  |         .get(headings::BILLABLE) | ||||||
|  |         .ok_or(anyhow!("Missing billable in CSV"))? | ||||||
|  |     { | ||||||
|  |         "Yes" => true, | ||||||
|  |         "No" => false, | ||||||
|  |         _ => unimplemented!("Unknown billable value"), | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     unimplemented!("Refactor model to support non-json sources") | ||||||
|  | } | ||||||
							
								
								
									
										63
									
								
								src/db.rs
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								src/db.rs
									
									
									
									
									
								
							| @ -1,23 +1,47 @@ | |||||||
| use crate::entity::{client, project, time_entry}; | use crate::entity::{client, project, time_entry}; | ||||||
| use crate::types::{Project, ProjectClient, ReportEntry, TimeEntry}; | use crate::toggl_api::types::{Client, Project, ReportRow, TimeEntry}; | ||||||
| use sea_orm::sea_query::OnConflict; | use sea_orm::sea_query::OnConflict; | ||||||
| use sea_orm::{NotSet, Set}; | use sea_orm::{NotSet, Set}; | ||||||
| 
 | 
 | ||||||
| impl ReportEntry { | impl ReportRow { | ||||||
|     pub(crate) fn as_models(&self) -> Vec<time_entry::ActiveModel> { |     pub fn to_time_entries(&self, workspace_id: i64) -> Vec<TimeEntry> { | ||||||
|         self.time_entries |         self.time_entries | ||||||
|             .iter() |             .iter() | ||||||
|             .map(|inner| time_entry::ActiveModel { |             .map(|inner| TimeEntry { | ||||||
|                 id: NotSet, |                 id: inner.id as i64, | ||||||
|                 toggl_id: Set(inner.id as i64), |                 description: self.description.clone(), | ||||||
|                 description: Set(self.description.clone()), |                 project_id: self.project_id.map(|id| id as i64), | ||||||
|                 project_id: Set(self.project_id.map(|id| id as i64)), |                 task_id: self.task_id.map(|id| id as i64), | ||||||
|                 start: Set(chrono::DateTime::parse_from_rfc3339(&inner.start).unwrap()), |                 billable: self.billable, | ||||||
|                 stop: Set(chrono::DateTime::parse_from_rfc3339(&inner.stop).unwrap()), |                 start: inner.start, | ||||||
|                 raw_json: Set(serde_json::to_value(inner).unwrap()), |                 stop: Some(inner.stop), | ||||||
|  |                 at: inner.at, | ||||||
|  |                 server_deleted_at: None, | ||||||
|  |                 tags: vec![], // TODO: tags on report row import, need to track in separate table
 | ||||||
|  |                 workspace_id, | ||||||
|  |                 duration: inner.seconds as i64, | ||||||
|  |                 tag_ids: self.tag_ids.iter().map(|ids| *ids as i64).collect(), | ||||||
|  |                 user_id: self.user_id as i64, | ||||||
|             }) |             }) | ||||||
|             .collect() |             .collect() | ||||||
|     } |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl TimeEntry { | ||||||
|  |     pub(crate) fn as_model(&self) -> time_entry::ActiveModel { | ||||||
|  |         time_entry::ActiveModel { | ||||||
|  |             id: NotSet, | ||||||
|  |             toggl_id: Set(self.id), | ||||||
|  |             description: Set(self.description.clone()), | ||||||
|  |             project_id: Set(self.project_id), | ||||||
|  |             start: Set(self.start.fixed_offset()), | ||||||
|  |             stop: Set(self.stop.unwrap().fixed_offset()), | ||||||
|  |             raw_json: Set(serde_json::to_value(self).unwrap()), | ||||||
|  |             server_updated_at: Set(self.at.fixed_offset()), | ||||||
|  |             server_deleted_at: Set(self.server_deleted_at.map(|dt| dt.fixed_offset())), | ||||||
|  |             tags: Set(serde_json::to_value(&self.tags).unwrap()), | ||||||
|  |         } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     pub fn grafting_conflict_statement() -> OnConflict { |     pub fn grafting_conflict_statement() -> OnConflict { | ||||||
|         OnConflict::column(time_entry::Column::TogglId) |         OnConflict::column(time_entry::Column::TogglId) | ||||||
| @ -27,20 +51,23 @@ impl ReportEntry { | |||||||
|                 time_entry::Column::Start, |                 time_entry::Column::Start, | ||||||
|                 time_entry::Column::Stop, |                 time_entry::Column::Stop, | ||||||
|                 time_entry::Column::RawJson, |                 time_entry::Column::RawJson, | ||||||
|  |                 time_entry::Column::ServerUpdatedAt, | ||||||
|  |                 time_entry::Column::ServerDeletedAt, | ||||||
|  |                 time_entry::Column::Tags, | ||||||
|             ]) |             ]) | ||||||
|             .to_owned() |             .to_owned() | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl ProjectClient { | impl Client { | ||||||
|     pub fn as_model(&self) -> client::ActiveModel { |     pub fn as_model(&self) -> client::ActiveModel { | ||||||
|         client::ActiveModel { |         client::ActiveModel { | ||||||
|             id: Set(self.id), |             id: Set(self.id), | ||||||
|             name: Set(self.name.clone()), |             name: Set(self.name.clone()), | ||||||
|             archived: Set(self.archived.clone()), |             archived: Set(self.archived), | ||||||
|             workspace_id: Set(self.wid), |             workspace_id: Set(self.wid), | ||||||
|             at: Set(self.at.clone().fixed_offset()), |             at: Set(self.at.clone().fixed_offset()), | ||||||
|             server_deleted_at: Set(self.server_deleted_at.clone().map(|dt| dt.fixed_offset())), |             server_deleted_at: Set(self.server_deleted_at.map(|dt| dt.fixed_offset())), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -67,6 +94,10 @@ impl Project { | |||||||
|             client_id: Set(self.client_id.map(|id| id as i32)), |             client_id: Set(self.client_id.map(|id| id as i32)), | ||||||
|             workspace_id: Set(self.workspace_id as i64), |             workspace_id: Set(self.workspace_id as i64), | ||||||
|             raw_json: Set(serde_json::to_value(self).unwrap()), |             raw_json: Set(serde_json::to_value(self).unwrap()), | ||||||
|  |             color: Set(self.color.clone()), | ||||||
|  |             server_created_at: Set(self.created_at.clone().fixed_offset()), | ||||||
|  |             server_updated_at: Set(self.at.clone().fixed_offset()), | ||||||
|  |             server_deleted_at: Set(self.server_deleted_at.map(|dt| dt.fixed_offset())), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -78,6 +109,10 @@ impl Project { | |||||||
|                 project::Column::ClientId, |                 project::Column::ClientId, | ||||||
|                 project::Column::WorkspaceId, |                 project::Column::WorkspaceId, | ||||||
|                 project::Column::RawJson, |                 project::Column::RawJson, | ||||||
|  |                 project::Column::Color, | ||||||
|  |                 project::Column::ServerCreatedAt, | ||||||
|  |                 project::Column::ServerUpdatedAt, | ||||||
|  |                 project::Column::ServerDeletedAt, | ||||||
|             ]) |             ]) | ||||||
|             .to_owned() |             .to_owned() | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2
 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2
 | ||||||
|  | #![allow(unused_imports)] | ||||||
| 
 | 
 | ||||||
| pub use super::client::Entity as Client; | pub use super::client::Entity as Client; | ||||||
| pub use super::project::Entity as Project; | pub use super::project::Entity as Project; | ||||||
|  | |||||||
| @ -16,6 +16,11 @@ pub struct Model { | |||||||
|     pub active: bool, |     pub active: bool, | ||||||
|     #[sea_orm(column_type = "JsonBinary")] |     #[sea_orm(column_type = "JsonBinary")] | ||||||
|     pub raw_json: Json, |     pub raw_json: Json, | ||||||
|  |     #[sea_orm(column_type = "Text")] | ||||||
|  |     pub color: String, | ||||||
|  |     pub server_created_at: DateTimeWithTimeZone, | ||||||
|  |     pub server_updated_at: DateTimeWithTimeZone, | ||||||
|  |     pub server_deleted_at: Option<DateTimeWithTimeZone>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||||
|  | |||||||
| @ -16,6 +16,10 @@ pub struct Model { | |||||||
|     pub stop: DateTimeWithTimeZone, |     pub stop: DateTimeWithTimeZone, | ||||||
|     #[sea_orm(column_type = "JsonBinary")] |     #[sea_orm(column_type = "JsonBinary")] | ||||||
|     pub raw_json: Json, |     pub raw_json: Json, | ||||||
|  |     #[sea_orm(column_type = "JsonBinary")] | ||||||
|  |     pub tags: Json, | ||||||
|  |     pub server_updated_at: DateTimeWithTimeZone, | ||||||
|  |     pub server_deleted_at: Option<DateTimeWithTimeZone>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||||
|  | |||||||
							
								
								
									
										222
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										222
									
								
								src/main.rs
									
									
									
									
									
								
							| @ -1,35 +1,19 @@ | |||||||
| use crate::client::TogglClient; | use crate::toggl_api::TogglApiClient; | ||||||
| use crate::entity::prelude::TimeEntry; |  | ||||||
| use crate::entity::time_entry; |  | ||||||
| use crate::entity::time_entry::ActiveModel; |  | ||||||
| use crate::types::{Current, Project, ProjectClient, ReportEntry, TogglQuery}; |  | ||||||
| use anyhow::anyhow; |  | ||||||
| use axum::extract::multipart::Field; |  | ||||||
| use axum::extract::{Multipart, Query}; |  | ||||||
| use axum::http::StatusCode; |  | ||||||
| use axum::response::IntoResponse; |  | ||||||
| use axum::routing::{get, post}; | use axum::routing::{get, post}; | ||||||
| use axum::{Extension, Json, Router}; | use axum::{Extension, Router}; | ||||||
| use base64::engine::general_purpose::STANDARD; |  | ||||||
| use base64::Engine; |  | ||||||
| use utils::{shutdown_signal, Result}; |  | ||||||
| use chrono::{NaiveDate, NaiveTime}; |  | ||||||
| use clap::Parser; | use clap::Parser; | ||||||
| use migration::{Migrator, MigratorTrait}; | use migration::{Migrator, MigratorTrait}; | ||||||
| use sea_orm::sea_query::IntoCondition; |  | ||||||
| use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter}; |  | ||||||
| use serde::Deserialize; |  | ||||||
| use serde_json::Value; |  | ||||||
| use std::collections::HashMap; |  | ||||||
| use std::net::SocketAddr; | use std::net::SocketAddr; | ||||||
| use tower_http::trace::TraceLayer; | use tower_http::trace::TraceLayer; | ||||||
| use tracing::{debug, instrument}; | use utils::{shutdown_signal, Result}; | ||||||
| 
 | 
 | ||||||
| mod client; | mod csv_parser; | ||||||
| mod db; | mod db; | ||||||
| mod entity; | mod entity; | ||||||
| mod poll; | mod poll; | ||||||
| mod types; | mod routes; | ||||||
|  | mod sync_service; | ||||||
|  | mod toggl_api; | ||||||
| mod utils; | mod utils; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone, Parser)] | #[derive(Debug, Clone, Parser)] | ||||||
| @ -51,186 +35,14 @@ struct Config { | |||||||
|     poll_period: u64, |     poll_period: u64, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[instrument(skip(db, toggl_client))] |  | ||||||
| pub async fn report( |  | ||||||
|     Extension(toggl_client): Extension<TogglClient>, |  | ||||||
|     Extension(db): Extension<DatabaseConnection>, |  | ||||||
|     Json(query): Json<TogglQuery>, |  | ||||||
| ) -> Result<Json<Vec<ReportEntry>>> { |  | ||||||
|     let report = toggl_client.full_report(&query).await?; |  | ||||||
|     debug!("Returned results: {:?}", report); |  | ||||||
| 
 |  | ||||||
|     // We don't perform any deletes on report-fetched entries
 |  | ||||||
|     cache_report(&db, &report, None).await?; |  | ||||||
| 
 |  | ||||||
|     Ok(Json(report)) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[instrument(skip_all)] |  | ||||||
| async fn cache_report( |  | ||||||
|     db: &DatabaseConnection, |  | ||||||
|     models: &Vec<ReportEntry>, |  | ||||||
|     exclusive_on: Option<Condition>, |  | ||||||
| ) -> Result<()> { |  | ||||||
|     let models = models.iter().flat_map(|entry| entry.as_models()); |  | ||||||
|     let models = models.collect::<Vec<_>>(); |  | ||||||
|     let ids = models |  | ||||||
|         .iter() |  | ||||||
|         .map(|entry| entry.toggl_id.clone().unwrap()) |  | ||||||
|         .collect::<Vec<_>>(); |  | ||||||
|     debug!("Caching report entries: {:?}", models); |  | ||||||
| 
 |  | ||||||
|     // TODO: Why is this needed?
 |  | ||||||
|     if models.is_empty() { |  | ||||||
|         return Ok(()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     TimeEntry::insert_many(models) |  | ||||||
|         .on_conflict(ReportEntry::grafting_conflict_statement()) |  | ||||||
|         .exec(db) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     if let Some(exclusive_on) = exclusive_on { |  | ||||||
|         TimeEntry::delete_many() |  | ||||||
|             .filter( |  | ||||||
|                 Condition::all() |  | ||||||
|                     .add(exclusive_on) |  | ||||||
|                     .add(time_entry::Column::TogglId.is_in(ids).not()), |  | ||||||
|             ) |  | ||||||
|             .exec(db) |  | ||||||
|             .await?; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[instrument(skip(toggl_client))] |  | ||||||
| pub async fn current( |  | ||||||
|     Extension(toggl_client): Extension<TogglClient>, |  | ||||||
| ) -> Result<Json<Option<Current>>> { |  | ||||||
|     Ok(toggl_client.get_current().await.map(Json)?) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[instrument(skip(toggl_client))] |  | ||||||
| pub async fn start_time_entry( |  | ||||||
|     Extension(toggl_client): Extension<TogglClient>, |  | ||||||
|     Json(body): Json<HashMap<String, Value>>, |  | ||||||
| ) -> Result<impl IntoResponse> { |  | ||||||
|     toggl_client.start_time_entry(body).await?; |  | ||||||
| 
 |  | ||||||
|     Ok((StatusCode::OK, "Ok")) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[instrument(skip(db, toggl_client))] |  | ||||||
| async fn projects( |  | ||||||
|     Extension(db): Extension<DatabaseConnection>, |  | ||||||
|     Extension(toggl_client): Extension<TogglClient>, |  | ||||||
| ) -> Result<Json<Vec<Project>>> { |  | ||||||
|     let projects = toggl_client.fetch_projects().await?; |  | ||||||
| 
 |  | ||||||
|     entity::project::Entity::insert_many(projects.iter().map(Project::as_model)) |  | ||||||
|         .on_conflict(Project::grafting_conflict_statement()) |  | ||||||
|         .exec(&db) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     Ok(Json(projects)) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[instrument(skip(toggl_client, db))] |  | ||||||
| async fn clients( |  | ||||||
|     Extension(db): Extension<DatabaseConnection>, |  | ||||||
|     Extension(toggl_client): Extension<TogglClient>, |  | ||||||
| ) -> Result<Json<Vec<ProjectClient>>> { |  | ||||||
|     let clients = toggl_client.fetch_clients().await?; |  | ||||||
|     entity::client::Entity::insert_many(clients.iter().map(ProjectClient::as_model)) |  | ||||||
|         .on_conflict(ProjectClient::grafting_conflict_statement()) |  | ||||||
|         .exec(&db) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     Ok(Json(clients)) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn health(Extension(toggl_client): Extension<TogglClient>) -> Result<&'static str> { |  | ||||||
|     return if toggl_client.check_health().await { |  | ||||||
|         Ok("Ok") |  | ||||||
|     } else { |  | ||||||
|         Err(anyhow!("Panopto health check failed").into()) |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, Clone, Deserialize)] |  | ||||||
| struct RefreshQuery { |  | ||||||
|     start_date: Option<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[instrument(skip(toggl_client, db))] |  | ||||||
| async fn refresh( |  | ||||||
|     Extension(toggl_client): Extension<TogglClient>, |  | ||||||
|     Extension(db): Extension<DatabaseConnection>, |  | ||||||
|     Query(RefreshQuery { start_date }): Query<RefreshQuery>, |  | ||||||
| ) -> Result<&'static str> { |  | ||||||
|     let end_date = chrono::Utc::now(); |  | ||||||
|     let end_date_query_string = end_date.date_naive().format("%Y-%m-%d").to_string(); |  | ||||||
|     let start_date_query_string = start_date.unwrap_or(end_date_query_string.clone()); |  | ||||||
|     let start_date = NaiveDate::parse_from_str(&start_date_query_string, "%Y-%m-%d")?; |  | ||||||
| 
 |  | ||||||
|     let query = TogglQuery { |  | ||||||
|         start_date: Some(start_date_query_string), |  | ||||||
|         end_date: Some(end_date_query_string), |  | ||||||
|         ..Default::default() |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     let report = toggl_client.full_report(&query).await?; |  | ||||||
|     let exclusivity_condition = day_exclusivity_condition(start_date, end_date.date_naive()); |  | ||||||
|     cache_report(&db, &report, Some(exclusivity_condition)).await?; |  | ||||||
| 
 |  | ||||||
|     Ok("Ok") |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn day_exclusivity_condition(start: NaiveDate, end: NaiveDate) -> Condition { |  | ||||||
|     time_entry::Column::Start |  | ||||||
|         .between( |  | ||||||
|             start.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()), |  | ||||||
|             end.and_time(NaiveTime::from_hms_opt(23, 59, 59).unwrap()), |  | ||||||
|         ) |  | ||||||
|         .into_condition() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| fn from_csv_row(row: csv::StringRecord) -> ActiveModel { |  | ||||||
|     unimplemented!("Need to refactor db first") |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn import_csv( |  | ||||||
|     Extension(db): Extension<DatabaseConnection>, |  | ||||||
|     mut multipart: Multipart, |  | ||||||
| ) -> Result<impl IntoResponse> { |  | ||||||
|     return Ok((StatusCode::NOT_IMPLEMENTED, "Not implemented")); |  | ||||||
| 
 |  | ||||||
|     // while let Some(field) = multipart.next_field().await? {
 |  | ||||||
|     //     // if let Some("csv") = field.name() {
 |  | ||||||
|     //     //     let csv = field.bytes().await?;
 |  | ||||||
|     //     //     let mut csv = csv::Reader::from_reader(csv.as_ref());
 |  | ||||||
|     //     //     let data = csv.records().filter_map(|f| f.ok()).map(from_csv_row);
 |  | ||||||
|     //     //
 |  | ||||||
|     //     //     time_entry::Entity::insert_many(data.collect::<Result<_>>().unwrap())
 |  | ||||||
|     //     //         .on_conflict(ReportEntry::grafting_conflict_statement())
 |  | ||||||
|     //     //         .exec(&db)
 |  | ||||||
|     //     //         .await
 |  | ||||||
|     //     //         .unwrap()
 |  | ||||||
|     //     // }
 |  | ||||||
|     // }
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[tokio::main] | #[tokio::main] | ||||||
| async fn main() -> Result<()> { | async fn main() -> Result<()> { | ||||||
|     // install global collector configured based on RUST_LOG env var.
 |     // install global collector configured based on RUST_LOG env var.
 | ||||||
|     tracing_subscriber::fmt::init(); |     tracing_subscriber::fmt::init(); | ||||||
| 
 | 
 | ||||||
|     let config = Config::parse(); |     let config = Config::parse(); | ||||||
|     let toggl_client = TogglClient::new( |     let toggl_client = | ||||||
|         &config.workspace_id.to_string(), |         TogglApiClient::new(&config.workspace_id.to_string(), &config.toggl_api_token); | ||||||
|         &STANDARD.encode(&format!("{}:api_token", config.toggl_api_token)), |  | ||||||
|     ); |  | ||||||
| 
 | 
 | ||||||
|     let db = sea_orm::Database::connect(config.database_url) |     let db = sea_orm::Database::connect(config.database_url) | ||||||
|         .await |         .await | ||||||
| @ -246,14 +58,14 @@ async fn main() -> Result<()> { | |||||||
| 
 | 
 | ||||||
|     // build our application with a route
 |     // build our application with a route
 | ||||||
|     let app = Router::new() |     let app = Router::new() | ||||||
|         .route("/import_csv", post(import_csv)) |         .route("/import_csv", post(routes::import_csv)) | ||||||
|         .route("/health", get(health)) |         .route("/health", get(routes::health)) | ||||||
|         .route("/current", get(current)) |         .route("/current", get(routes::current)) | ||||||
|         .route("/refresh", post(refresh)) |         .route("/refresh", post(routes::refresh)) | ||||||
|         .route("/report", post(report)) |         .route("/report", post(routes::report)) | ||||||
|         .route("/projects", get(projects)) |         .route("/projects", get(routes::projects)) | ||||||
|         .route("/clients", get(clients)) |         .route("/clients", get(routes::clients)) | ||||||
|         .route("/start_time_entry", post(start_time_entry)) |         .route("/start_time_entry", post(routes::start_time_entry)) | ||||||
|         .layer(Extension(toggl_client)) |         .layer(Extension(toggl_client)) | ||||||
|         .layer(Extension(db)) |         .layer(Extension(db)) | ||||||
|         .layer(TraceLayer::new_for_http()); |         .layer(TraceLayer::new_for_http()); | ||||||
|  | |||||||
							
								
								
									
										89
									
								
								src/poll.rs
									
									
									
									
									
								
							
							
						
						
									
										89
									
								
								src/poll.rs
									
									
									
									
									
								
							| @ -1,23 +1,23 @@ | |||||||
| use crate::client::TogglClient; | use crate::entity::time_entry; | ||||||
| use crate::entity::{client, project, time_entry}; | use crate::sync_service::{update_database, UpdateStats}; | ||||||
| use crate::types::{Project, ProjectClient, TogglQuery}; | use crate::toggl_api::TogglApiClient; | ||||||
| use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect}; | use crate::utils; | ||||||
|  | use chrono::{DateTime, FixedOffset}; | ||||||
|  | use migration::Order; | ||||||
|  | use sea_orm::{DatabaseConnection, EntityTrait, QueryOrder, QuerySelect}; | ||||||
|  | use std::ops::Sub; | ||||||
| use tracing::instrument; | use tracing::instrument; | ||||||
| use crate::{day_exclusivity_condition, utils}; |  | ||||||
| 
 | 
 | ||||||
| #[tracing::instrument(skip(client, db))] | #[tracing::instrument(skip(client, db))] | ||||||
| pub async fn poll_job(client: TogglClient, db: DatabaseConnection, poll_period: u64) { | pub async fn poll_job(client: TogglApiClient, db: DatabaseConnection, poll_period: u64) { | ||||||
|     // Every 2h, poll the Toggl API for new time entries for today to cache them in the database
 |     // Periodically poll the Toggl API for new time entries for today to cache them in the database
 | ||||||
|     let period = tokio::time::Duration::from_secs(poll_period); |     let period = tokio::time::Duration::from_secs(poll_period); | ||||||
| 
 | 
 | ||||||
|     loop { |     loop { | ||||||
|         tracing::info!("Polling Toggl API"); |         tracing::info!("Polling Toggl API"); | ||||||
|         match perform_poll(&client, &db).await { |         match perform_poll(&client, &db).await { | ||||||
|             Ok(report_entries_count) => { |             Ok(poll_update_data) => { | ||||||
|                 tracing::info!( |                 tracing::info!("Successfully polled Toggl API: {:?}", poll_update_data); | ||||||
|                     "Successfully polled Toggl API: {:?} entries retrieved", |  | ||||||
|                     report_entries_count |  | ||||||
|                 ); |  | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             Err(error) => { |             Err(error) => { | ||||||
| @ -29,60 +29,29 @@ pub async fn poll_job(client: TogglClient, db: DatabaseConnection, poll_period: | |||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[instrument(skip(client, db))] | #[instrument(skip(toggl_client, db))] | ||||||
| pub async fn perform_poll( | pub async fn perform_poll( | ||||||
|     client: &TogglClient, |     toggl_client: &TogglApiClient, | ||||||
|     db: &DatabaseConnection, |     db: &DatabaseConnection, | ||||||
| ) -> utils::Result<usize> { | ) -> utils::Result<UpdateStats> { | ||||||
|     let now = chrono::Utc::now(); |     let since = time_entry::Entity::find() | ||||||
|     let today_string = now |  | ||||||
|         .date_naive() |  | ||||||
|         .format("%Y-%m-%d") |  | ||||||
|         .to_string(); |  | ||||||
| 
 |  | ||||||
|     let report = client |  | ||||||
|         .full_report(&TogglQuery { |  | ||||||
|             start_date: Some(today_string.clone()), |  | ||||||
|             end_date: Some(today_string.clone()), |  | ||||||
|             ..Default::default() |  | ||||||
|         }) |  | ||||||
|         .await?; |  | ||||||
| 
 |  | ||||||
|     let existing_project_ids = project::Entity::find() |  | ||||||
|         .select_only() |         .select_only() | ||||||
|         .column(project::Column::TogglId) |         .column(time_entry::Column::ServerUpdatedAt) | ||||||
|         .into_tuple::<i64>() |         .order_by(time_entry::Column::ServerUpdatedAt, Order::Desc) | ||||||
|         .all(db) |         .into_tuple::<DateTime<FixedOffset>>() | ||||||
|  |         .one(db) | ||||||
|         .await?; |         .await?; | ||||||
| 
 | 
 | ||||||
|     let new_projects = report |     let since = since.unwrap_or( | ||||||
|         .iter() |         chrono::Utc::now() | ||||||
|         .filter_map(|entry| entry.project_id) |             .sub(chrono::Duration::days(1)) | ||||||
|         .any(|project_id| !existing_project_ids.contains(&(project_id as i64))); |             .fixed_offset(), | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     if new_projects { |     let time_entries = toggl_client | ||||||
|         let clients = client.fetch_clients().await?; |         .fetch_time_entries_modified_since(since.to_utc()) | ||||||
| 
 |  | ||||||
|         client::Entity::insert_many(clients.iter().map(ProjectClient::as_model)) |  | ||||||
|             .on_conflict(ProjectClient::grafting_conflict_statement()) |  | ||||||
|             .exec(db) |  | ||||||
|         .await?; |         .await?; | ||||||
| 
 | 
 | ||||||
|         let projects = client.fetch_projects().await?; |     // These are changes only so there is no need to enforce exclusivity
 | ||||||
| 
 |     update_database(db, toggl_client, &time_entries, None).await | ||||||
|         project::Entity::insert_many(projects.iter().map(Project::as_model)) |  | ||||||
|             .on_conflict(Project::grafting_conflict_statement()) |  | ||||||
|             .exec(db) |  | ||||||
|             .await?; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     crate::cache_report( |  | ||||||
|         &db, |  | ||||||
|         &report, |  | ||||||
|         Some( |  | ||||||
|             day_exclusivity_condition(now.date_naive(), now.date_naive()), |  | ||||||
|         ), |  | ||||||
|     ) |  | ||||||
|     .await?; |  | ||||||
|     Ok(report.len()) |  | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										149
									
								
								src/routes.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								src/routes.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,149 @@ | |||||||
|  | use crate::sync_service::UpdateStats; | ||||||
|  | use crate::toggl_api::types::{self, Client, Project, ReportRow, TogglReportQuery}; | ||||||
|  | use crate::toggl_api::TogglApiClient; | ||||||
|  | use crate::{entity, sync_service, utils}; | ||||||
|  | use anyhow::anyhow; | ||||||
|  | use axum::extract::{Multipart, Query}; | ||||||
|  | use axum::http::StatusCode; | ||||||
|  | use axum::response::IntoResponse; | ||||||
|  | use axum::{Extension, Json}; | ||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use sea_orm::{DatabaseConnection, EntityTrait}; | ||||||
|  | use serde::Deserialize; | ||||||
|  | use serde_json::Value; | ||||||
|  | use std::collections::HashMap; | ||||||
|  | use tracing::instrument; | ||||||
|  | 
 | ||||||
|  | #[instrument(skip(db, toggl_client))] | ||||||
|  | pub async fn report( | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  |     Extension(db): Extension<DatabaseConnection>, | ||||||
|  |     Json(query): Json<TogglReportQuery>, | ||||||
|  | ) -> utils::Result<Json<Vec<ReportRow>>> { | ||||||
|  |     let report = toggl_client.full_report(&query).await?; | ||||||
|  |     let time_entries = report | ||||||
|  |         .iter() | ||||||
|  |         .flat_map(|entry| entry.to_time_entries(toggl_client.workspace_id())) | ||||||
|  |         .collect::<Vec<_>>(); | ||||||
|  |     sync_service::update_database(&db, &toggl_client, &time_entries, None).await?; | ||||||
|  | 
 | ||||||
|  |     Ok(Json(report)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[instrument(skip(toggl_client))] | ||||||
|  | pub async fn current( | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  | ) -> utils::Result<Json<Option<types::TimeEntry>>> { | ||||||
|  |     toggl_client.fetch_current_time_entry().await.map(Json) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[instrument(skip(toggl_client))] | ||||||
|  | pub async fn start_time_entry( | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  |     Json(body): Json<HashMap<String, Value>>, | ||||||
|  | ) -> utils::Result<impl IntoResponse> { | ||||||
|  |     toggl_client.start_time_entry(body).await?; | ||||||
|  | 
 | ||||||
|  |     Ok((StatusCode::OK, "Ok")) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[instrument(skip(db, toggl_client))] | ||||||
|  | pub async fn projects( | ||||||
|  |     Extension(db): Extension<DatabaseConnection>, | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  | ) -> utils::Result<Json<Vec<Project>>> { | ||||||
|  |     let projects = toggl_client.fetch_projects().await?; | ||||||
|  | 
 | ||||||
|  |     entity::project::Entity::insert_many(projects.iter().map(Project::as_model)) | ||||||
|  |         .on_conflict(Project::grafting_conflict_statement()) | ||||||
|  |         .exec(&db) | ||||||
|  |         .await?; | ||||||
|  | 
 | ||||||
|  |     Ok(Json(projects)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[instrument(skip(toggl_client, db))] | ||||||
|  | pub async fn clients( | ||||||
|  |     Extension(db): Extension<DatabaseConnection>, | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  | ) -> utils::Result<Json<Vec<Client>>> { | ||||||
|  |     let clients = toggl_client.fetch_clients().await?; | ||||||
|  |     entity::client::Entity::insert_many(clients.iter().map(Client::as_model)) | ||||||
|  |         .on_conflict(Client::grafting_conflict_statement()) | ||||||
|  |         .exec(&db) | ||||||
|  |         .await?; | ||||||
|  | 
 | ||||||
|  |     Ok(Json(clients)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub async fn health( | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  | ) -> utils::Result<&'static str> { | ||||||
|  |     if toggl_client.check_health().await { | ||||||
|  |         Ok("Ok") | ||||||
|  |     } else { | ||||||
|  |         Err(anyhow!("Toggl health check failed").into()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub async fn import_csv( | ||||||
|  |     Extension(db): Extension<DatabaseConnection>, | ||||||
|  |     mut multipart: Multipart, | ||||||
|  | ) -> utils::Result<impl IntoResponse> { | ||||||
|  |     return Ok((StatusCode::NOT_IMPLEMENTED, "Not implemented")); | ||||||
|  | 
 | ||||||
|  |     // while let Some(field) = multipart.next_field().await? {
 | ||||||
|  |     //     // if let Some("csv") = field.name() {
 | ||||||
|  |     //     //     let csv = field.bytes().await?;
 | ||||||
|  |     //     //     let mut csv = csv::Reader::from_reader(csv.as_ref());
 | ||||||
|  |     //     //     let data = csv.records().filter_map(|f| f.ok()).map(from_csv_row);
 | ||||||
|  |     //     //
 | ||||||
|  |     //     //     time_entry::Entity::insert_many(data.collect::<Result<_>>().unwrap())
 | ||||||
|  |     //     //         .on_conflict(ReportEntry::grafting_conflict_statement())
 | ||||||
|  |     //     //         .exec(&db)
 | ||||||
|  |     //     //         .await
 | ||||||
|  |     //     //         .unwrap()
 | ||||||
|  |     //     // }
 | ||||||
|  |     // }
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Clone, Deserialize)] | ||||||
|  | pub struct RefreshQuery { | ||||||
|  |     start_date: Option<DateTime<Utc>>, | ||||||
|  |     end_date: Option<DateTime<Utc>>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[instrument(skip(toggl_client, db))] | ||||||
|  | pub async fn refresh( | ||||||
|  |     Extension(toggl_client): Extension<TogglApiClient>, | ||||||
|  |     Extension(db): Extension<DatabaseConnection>, | ||||||
|  |     Query(RefreshQuery { | ||||||
|  |         start_date, | ||||||
|  |         end_date, | ||||||
|  |     }): Query<RefreshQuery>, | ||||||
|  | ) -> utils::Result<Json<UpdateStats>> { | ||||||
|  |     let time_entries = match (start_date, end_date) { | ||||||
|  |         (Some(start_date), Some(end_date)) => { | ||||||
|  |             toggl_client | ||||||
|  |                 .fetch_time_entries_in_range(start_date, end_date) | ||||||
|  |                 .await? | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         (Some(start_date), None) => { | ||||||
|  |             let end_date = Utc::now(); | ||||||
|  |             toggl_client | ||||||
|  |                 .fetch_time_entries_in_range(start_date, end_date) | ||||||
|  |                 .await? | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         (None, Some(_)) => { | ||||||
|  |             return Err(anyhow!("start_date must be provided if end_date is provided").into()); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         _ => toggl_client.fetch_recent_time_entries().await?, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     sync_service::update_database(&db, &toggl_client, &time_entries, None) | ||||||
|  |         .await | ||||||
|  |         .map(Json) | ||||||
|  | } | ||||||
							
								
								
									
										118
									
								
								src/sync_service.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/sync_service.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,118 @@ | |||||||
|  | use crate::entity::time_entry::Entity as TimeEntry; | ||||||
|  | use crate::entity::{client, project, time_entry}; | ||||||
|  | use crate::toggl_api::types::{Client, Project, TimeEntry as ToggleApiTimeEntry}; | ||||||
|  | use crate::toggl_api::TogglApiClient; | ||||||
|  | use crate::utils; | ||||||
|  | use migration::Condition; | ||||||
|  | use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect}; | ||||||
|  | use serde::Serialize; | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct UpdateStats { | ||||||
|  |     retrieved: UpdateStatsInner, | ||||||
|  |     written: UpdateStatsInner, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct UpdateStatsInner { | ||||||
|  |     updated: usize, | ||||||
|  |     deleted: usize, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub async fn update_database( | ||||||
|  |     db: &DatabaseConnection, | ||||||
|  |     toggl_client: &TogglApiClient, | ||||||
|  |     time_entries: &[ToggleApiTimeEntry], | ||||||
|  |     exclusive_on: Option<Condition>, | ||||||
|  | ) -> utils::Result<UpdateStats> { | ||||||
|  |     let (deleted_entries, time_entries) = time_entries | ||||||
|  |         .iter() | ||||||
|  |         .partition::<Vec<_>, _>(|entry| entry.server_deleted_at.is_some()); | ||||||
|  | 
 | ||||||
|  |     let retrieved = UpdateStatsInner { | ||||||
|  |         updated: time_entries.len(), | ||||||
|  |         deleted: deleted_entries.len(), | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     let mut written = UpdateStatsInner { | ||||||
|  |         updated: 0, | ||||||
|  |         deleted: 0, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     let deleted_ids = deleted_entries | ||||||
|  |         .iter() | ||||||
|  |         .map(|entry| entry.id) | ||||||
|  |         .collect::<Vec<_>>(); | ||||||
|  | 
 | ||||||
|  |     if !deleted_ids.is_empty() { | ||||||
|  |         let delete_result = TimeEntry::delete_many() | ||||||
|  |             .filter(time_entry::Column::TogglId.is_in(deleted_ids)) | ||||||
|  |             .exec(db) | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         written.deleted = delete_result.rows_affected as usize; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let existing_project_ids = project::Entity::find() | ||||||
|  |         .select_only() | ||||||
|  |         .column(project::Column::TogglId) | ||||||
|  |         .into_tuple::<i64>() | ||||||
|  |         .all(db) | ||||||
|  |         .await?; | ||||||
|  | 
 | ||||||
|  |     let new_projects = time_entries | ||||||
|  |         .iter() | ||||||
|  |         .filter_map(|entry| entry.project_id) | ||||||
|  |         .any(|project_id| !existing_project_ids.contains(&project_id)); | ||||||
|  | 
 | ||||||
|  |     if new_projects { | ||||||
|  |         let clients = toggl_client.fetch_clients().await?; | ||||||
|  | 
 | ||||||
|  |         client::Entity::insert_many(clients.iter().map(Client::as_model)) | ||||||
|  |             .on_conflict(Client::grafting_conflict_statement()) | ||||||
|  |             .exec(db) | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         let projects = toggl_client.fetch_projects().await?; | ||||||
|  | 
 | ||||||
|  |         project::Entity::insert_many(projects.iter().map(Project::as_model)) | ||||||
|  |             .on_conflict(Project::grafting_conflict_statement()) | ||||||
|  |             .exec(db) | ||||||
|  |             .await?; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let ids = time_entries | ||||||
|  |         .iter() | ||||||
|  |         .map(|entry| entry.id) | ||||||
|  |         .collect::<Vec<_>>(); | ||||||
|  | 
 | ||||||
|  |     let models = time_entries | ||||||
|  |         .into_iter() | ||||||
|  |         .map(|entry| entry.as_model()) | ||||||
|  |         .collect::<Vec<_>>(); | ||||||
|  | 
 | ||||||
|  |     // TODO: Why is this needed?
 | ||||||
|  |     if models.is_empty() { | ||||||
|  |         return Ok(UpdateStats { retrieved, written }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let insert_result = TimeEntry::insert_many(models) | ||||||
|  |         .on_conflict(ToggleApiTimeEntry::grafting_conflict_statement()) | ||||||
|  |         .exec_without_returning(db) | ||||||
|  |         .await?; | ||||||
|  | 
 | ||||||
|  |     written.updated = insert_result as usize; | ||||||
|  | 
 | ||||||
|  |     if let Some(exclusive_on) = exclusive_on { | ||||||
|  |         TimeEntry::delete_many() | ||||||
|  |             .filter( | ||||||
|  |                 Condition::all() | ||||||
|  |                     .add(exclusive_on) | ||||||
|  |                     .add(time_entry::Column::TogglId.is_in(ids).not()), | ||||||
|  |             ) | ||||||
|  |             .exec(db) | ||||||
|  |             .await?; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Ok(UpdateStats { retrieved, written }) | ||||||
|  | } | ||||||
							
								
								
									
										229
									
								
								src/toggl_api/api_client.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								src/toggl_api/api_client.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,229 @@ | |||||||
|  | use crate::toggl_api::types::{ | ||||||
|  |     Client as ProjectClient, Project, ReportRow, TimeEntry, TogglReportQuery, | ||||||
|  | }; | ||||||
|  | use anyhow::anyhow; | ||||||
|  | use axum::http::StatusCode; | ||||||
|  | use base64::engine::general_purpose::STANDARD; | ||||||
|  | use base64::Engine; | ||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use hyper::HeaderMap; | ||||||
|  | use reqwest::header::HeaderValue; | ||||||
|  | use reqwest::{Client, RequestBuilder, Response}; | ||||||
|  | use serde_json::Value; | ||||||
|  | use std::collections::HashMap; | ||||||
|  | use std::time::Duration; | ||||||
|  | use tracing::instrument; | ||||||
|  | use tracing::log::debug; | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct TogglApiClient { | ||||||
|  |     client: Client, | ||||||
|  |     workspace_id: String, | ||||||
|  |     base_url: String, | ||||||
|  |     reports_base_url: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl TogglApiClient { | ||||||
|  |     async fn make_request(&self, request_builder: RequestBuilder) -> crate::Result<Response> { | ||||||
|  |         loop { | ||||||
|  |             let builder = request_builder | ||||||
|  |                 .try_clone() | ||||||
|  |                 .ok_or(anyhow!("Failed to clone request builder"))?; | ||||||
|  |             let response = self.client.execute(builder.build()?).await?; | ||||||
|  | 
 | ||||||
|  |             // If we are rate limited, wait a bit and try again
 | ||||||
|  |             if response.status() == StatusCode::TOO_MANY_REQUESTS { | ||||||
|  |                 tokio::time::sleep(Duration::from_secs(5)).await; | ||||||
|  |             } else { | ||||||
|  |                 return Ok(response); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn workspace_id(&self) -> i64 { | ||||||
|  |         self.workspace_id.parse().unwrap() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn check_health(&self) -> bool { | ||||||
|  |         true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn new(workspace_id: &str, api_token: &str) -> Self { | ||||||
|  |         let toggl_auth = &STANDARD.encode(format!("{}:api_token", api_token)); | ||||||
|  | 
 | ||||||
|  |         let client = Client::builder() | ||||||
|  |             .default_headers(Self::default_headers(toggl_auth)) | ||||||
|  |             .build() | ||||||
|  |             .expect("Failed to build reqwest client"); | ||||||
|  | 
 | ||||||
|  |         Self { | ||||||
|  |             client, | ||||||
|  |             workspace_id: workspace_id.to_string(), | ||||||
|  |             base_url: "https://api.track.toggl.com/api/v9".to_string(), | ||||||
|  |             reports_base_url: "https://api.track.toggl.com/reports/api/v3".to_string(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn default_headers(toggl_auth: &str) -> HeaderMap { | ||||||
|  |         let mut headers = HeaderMap::new(); | ||||||
|  |         let mut value = HeaderValue::from_str(&format!("Basic {}", toggl_auth)).unwrap(); | ||||||
|  |         value.set_sensitive(true); | ||||||
|  |         headers.insert("Authorization", value); | ||||||
|  |         headers | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn fetch_projects(&self) -> crate::Result<Vec<Project>> { | ||||||
|  |         let url = format!( | ||||||
|  |             "{base_url}/workspaces/{}/projects", | ||||||
|  |             self.workspace_id, | ||||||
|  |             base_url = self.base_url, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         let projects = self | ||||||
|  |             .make_request(self.client.get(&url)) | ||||||
|  |             .await? | ||||||
|  |             .json::<Vec<Project>>() | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         Ok(projects) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn fetch_clients(&self) -> crate::Result<Vec<ProjectClient>> { | ||||||
|  |         let url = format!( | ||||||
|  |             "{base_url}/workspaces/{}/clients", | ||||||
|  |             self.workspace_id, | ||||||
|  |             base_url = self.base_url, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         let clients = self | ||||||
|  |             .make_request(self.client.get(&url)) | ||||||
|  |             .await? | ||||||
|  |             .json::<Vec<ProjectClient>>() | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         Ok(clients) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn fetch_recent_time_entries(&self) -> crate::Result<Vec<TimeEntry>> { | ||||||
|  |         let url = format!("{base_url}/me/time_entries", base_url = self.base_url); | ||||||
|  | 
 | ||||||
|  |         Ok(self | ||||||
|  |             .make_request(self.client.get(url)) | ||||||
|  |             .await? | ||||||
|  |             .json::<Vec<TimeEntry>>() | ||||||
|  |             .await?) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn fetch_time_entries_modified_since( | ||||||
|  |         &self, | ||||||
|  |         date_time: DateTime<Utc>, | ||||||
|  |     ) -> crate::Result<Vec<TimeEntry>> { | ||||||
|  |         let url = format!("{base_url}/me/time_entries", base_url = self.base_url); | ||||||
|  | 
 | ||||||
|  |         Ok(self | ||||||
|  |             .make_request( | ||||||
|  |                 self.client | ||||||
|  |                     .get(url) | ||||||
|  |                     .query(&[("since", date_time.timestamp())]), | ||||||
|  |             ) | ||||||
|  |             .await? | ||||||
|  |             .json::<Vec<TimeEntry>>() | ||||||
|  |             .await?) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn fetch_time_entries_in_range( | ||||||
|  |         &self, | ||||||
|  |         start: DateTime<Utc>, | ||||||
|  |         end: DateTime<Utc>, | ||||||
|  |     ) -> crate::Result<Vec<TimeEntry>> { | ||||||
|  |         let url = format!("{base_url}/me/time_entries", base_url = self.base_url); | ||||||
|  | 
 | ||||||
|  |         Ok(self | ||||||
|  |             .make_request(self.client.get(url).query(&[ | ||||||
|  |                 ("start_date", start.to_rfc3339()), | ||||||
|  |                 ("end_date", end.to_rfc3339()), | ||||||
|  |             ])) | ||||||
|  |             .await? | ||||||
|  |             .json::<Vec<TimeEntry>>() | ||||||
|  |             .await?) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn fetch_current_time_entry(&self) -> crate::Result<Option<TimeEntry>> { | ||||||
|  |         let url = format!( | ||||||
|  |             "{base_url}/me/time_entries/current", | ||||||
|  |             base_url = self.base_url | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         let res = self | ||||||
|  |             .make_request(self.client.get(url)) | ||||||
|  |             .await? | ||||||
|  |             .json::<Option<TimeEntry>>() | ||||||
|  |             .await?; | ||||||
|  | 
 | ||||||
|  |         Ok(res) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn start_time_entry(&self, mut body: HashMap<String, Value>) -> crate::Result<()> { | ||||||
|  |         let url = format!( | ||||||
|  |             "{base_url}/workspaces/{workspace_id}/time_entries", | ||||||
|  |             base_url = self.base_url, | ||||||
|  |             workspace_id = self.workspace_id | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         body.insert( | ||||||
|  |             "workspace_id".to_string(), | ||||||
|  |             self.workspace_id.parse::<i32>()?.into(), | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         self.make_request(self.client.post(url).json(&body)).await?; | ||||||
|  | 
 | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /////////////
 | ||||||
|  |     // Reports //
 | ||||||
|  |     /////////////
 | ||||||
|  | 
 | ||||||
|  |     fn paginate_filters(original_filters: &TogglReportQuery, last_row_id: u64) -> TogglReportQuery { | ||||||
|  |         let mut filters: TogglReportQuery = original_filters.clone(); | ||||||
|  |         filters.first_row_number = Some(last_row_id + 1); | ||||||
|  |         filters | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     #[instrument(skip(self, filters))] | ||||||
|  |     pub async fn full_report(&self, filters: &TogglReportQuery) -> crate::Result<Vec<ReportRow>> { | ||||||
|  |         let url = format!( | ||||||
|  |             "{base_url}/workspace/{workspace_id}/search/time_entries", | ||||||
|  |             base_url = self.reports_base_url, | ||||||
|  |             workspace_id = self.workspace_id | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         let mut last_row_number = Some(0); | ||||||
|  |         let mut results = vec![]; | ||||||
|  | 
 | ||||||
|  |         while let Some(last_row_number_n) = last_row_number { | ||||||
|  |             debug!("Fetching page starting with {}", last_row_number_n); | ||||||
|  |             // If we are not on the first page, wait a bit to avoid rate limiting
 | ||||||
|  |             if last_row_number_n != 0 { | ||||||
|  |                 tokio::time::sleep(Duration::from_secs(1)).await; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             // TODO: Implement rate limiting
 | ||||||
|  |             let response = self | ||||||
|  |                 .make_request( | ||||||
|  |                     self.client | ||||||
|  |                         .post(&url) | ||||||
|  |                         .json(&Self::paginate_filters(filters, last_row_number_n)), | ||||||
|  |                 ) | ||||||
|  |                 .await?; | ||||||
|  | 
 | ||||||
|  |             let data = response.json::<Vec<ReportRow>>().await?; | ||||||
|  | 
 | ||||||
|  |             last_row_number = data.last().map(|e| e.row_number as u64); | ||||||
|  | 
 | ||||||
|  |             data.into_iter().for_each(|e| results.push(e)); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         Ok(results) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								src/toggl_api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/toggl_api/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | |||||||
|  | pub mod api_client; | ||||||
|  | pub mod types; | ||||||
|  | 
 | ||||||
|  | pub use api_client::TogglApiClient; | ||||||
| @ -1,21 +1,12 @@ | |||||||
|  | use chrono::{DateTime, Utc}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use serde_json::Value; | use serde_json::Value; | ||||||
| use serde_with::skip_serializing_none; | use serde_with::skip_serializing_none; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use std::option::Option; | use std::option::Option; | ||||||
| use chrono::{DateTime, Utc}; |  | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Serialize, Deserialize, Debug)] | #[derive(Clone, Serialize, Deserialize, Debug)] | ||||||
| pub struct TimeEntry { | pub struct ReportRow { | ||||||
|     pub id: u64, |  | ||||||
|     pub seconds: u32, |  | ||||||
|     pub start: String, |  | ||||||
|     pub stop: String, |  | ||||||
|     pub at: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Serialize, Deserialize, Debug)] |  | ||||||
| pub struct ReportEntry { |  | ||||||
|     pub user_id: u32, |     pub user_id: u32, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub project_id: Option<u64>, |     pub project_id: Option<u64>, | ||||||
| @ -26,23 +17,40 @@ pub struct ReportEntry { | |||||||
|     pub billable_amount_in_cents: Option<u64>, |     pub billable_amount_in_cents: Option<u64>, | ||||||
|     pub hourly_rate_in_cents: Option<u64>, |     pub hourly_rate_in_cents: Option<u64>, | ||||||
|     pub currency: String, |     pub currency: String, | ||||||
|     pub time_entries: Vec<TimeEntry>, |     pub time_entries: Vec<ReportRowInnerTimeEntry>, | ||||||
|     pub row_number: u32, |     pub row_number: u32, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Clone, Serialize, Deserialize, Debug)] | ||||||
| pub struct Current { | pub struct ReportRowInnerTimeEntry { | ||||||
|     pub id: u64, |     pub id: u64, | ||||||
|     pub workspace_id: u64, |     pub seconds: u32, | ||||||
|     pub project_id: Option<u64>, |     pub start: DateTime<Utc>, | ||||||
|     pub task_id: Option<u64>, |     pub stop: DateTime<Utc>, | ||||||
|  |     pub at: DateTime<Utc>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize)] | ||||||
|  | pub struct TimeEntry { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub workspace_id: i64, | ||||||
|  |     pub project_id: Option<i64>, | ||||||
|  |     pub task_id: Option<i64>, | ||||||
|     pub billable: bool, |     pub billable: bool, | ||||||
|     pub start: String, |     pub start: DateTime<Utc>, | ||||||
|     pub stop: Option<String>, |     pub stop: Option<DateTime<Utc>>, | ||||||
|     pub duration: i64, |     pub duration: i64, | ||||||
|     pub description: String, |     pub description: String, | ||||||
|     pub tags: Vec<String>, |     pub tags: Vec<String>, | ||||||
|     pub tag_ids: Vec<u64>, |     pub tag_ids: Vec<i64>, | ||||||
|  |     pub at: DateTime<Utc>, | ||||||
|  |     pub server_deleted_at: Option<DateTime<Utc>>, | ||||||
|  |     pub user_id: i64, | ||||||
|  |     // Ignored fields
 | ||||||
|  |     // duronly: bool,
 | ||||||
|  |     // uid: i64,
 | ||||||
|  |     // wid: i64,
 | ||||||
|  |     // pid: Option<i64>,
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| @ -52,14 +60,34 @@ pub struct Project { | |||||||
|     pub client_id: Option<u64>, |     pub client_id: Option<u64>, | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub active: bool, |     pub active: bool, | ||||||
| 
 |     pub color: String, | ||||||
|     #[serde(flatten)] |     pub at: DateTime<Utc>, | ||||||
|     pub rest: HashMap<String, Value>, |     pub server_deleted_at: Option<DateTime<Utc>>, | ||||||
|  |     pub created_at: DateTime<Utc>, | ||||||
|  |     // cid: Option<serde_json::Value>,
 | ||||||
|  |     // wid: i64,
 | ||||||
|  |     // rate: Option<serde_json::Value>,
 | ||||||
|  |     // status: String,
 | ||||||
|  |     // billable: Option<serde_json::Value>,
 | ||||||
|  |     // currency: Option<serde_json::Value>,
 | ||||||
|  |     // template: Option<serde_json::Value>,
 | ||||||
|  |     // fixed_fee: Option<serde_json::Value>,
 | ||||||
|  |     // recurring: bool,
 | ||||||
|  |     // is_private: bool,
 | ||||||
|  |     // start_date: String,
 | ||||||
|  |     // template_id: Option<serde_json::Value>,
 | ||||||
|  |     // actual_hours: i64,
 | ||||||
|  |     // actual_seconds: i64,
 | ||||||
|  |     // auto_estimates: Option<serde_json::Value>,
 | ||||||
|  |     // estimated_hours: Option<serde_json::Value>,
 | ||||||
|  |     // estimated_seconds: Option<serde_json::Value>,
 | ||||||
|  |     // rate_last_updated: Option<serde_json::Value>,
 | ||||||
|  |     // recurring_parameters: Option<serde_json::Value>,
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Represents a client in Toggl.
 | /// Represents a client in Toggl.
 | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct ProjectClient { | pub struct Client { | ||||||
|     /// Indicates whether the client is archived or not.
 |     /// Indicates whether the client is archived or not.
 | ||||||
|     pub archived: bool, |     pub archived: bool, | ||||||
| 
 | 
 | ||||||
| @ -79,10 +107,9 @@ pub struct ProjectClient { | |||||||
|     pub wid: i32, |     pub wid: i32, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[allow(non_snake_case)] |  | ||||||
| #[skip_serializing_none] | #[skip_serializing_none] | ||||||
| #[derive(Serialize, Deserialize, Clone, Default)] | #[derive(Serialize, Deserialize, Clone, Default)] | ||||||
| pub struct TogglQuery { | pub struct TogglReportQuery { | ||||||
|     pub billable: Option<bool>, |     pub billable: Option<bool>, | ||||||
|     pub client_ids: Option<Vec<u64>>, |     pub client_ids: Option<Vec<u64>>, | ||||||
|     pub description: Option<String>, |     pub description: Option<String>, | ||||||
| @ -97,11 +124,13 @@ pub struct TogglQuery { | |||||||
|     pub min_duration_seconds: Option<u64>, |     pub min_duration_seconds: Option<u64>, | ||||||
|     pub order_by: Option<String>, |     pub order_by: Option<String>, | ||||||
|     pub order_dir: Option<String>, |     pub order_dir: Option<String>, | ||||||
|     pub postedFields: Option<Vec<String>>, |     #[serde(rename = "postedFields")] | ||||||
|  |     pub posted_fields: Option<Vec<String>>, | ||||||
|     pub project_ids: Option<Vec<u64>>, |     pub project_ids: Option<Vec<u64>>, | ||||||
|     pub rounding: Option<u64>, |     pub rounding: Option<u64>, | ||||||
|     pub rounding_minutes: Option<u64>, |     pub rounding_minutes: Option<u64>, | ||||||
|     pub startTime: Option<String>, |     #[serde(rename = "startTime")] | ||||||
|  |     pub start_time: Option<String>, | ||||||
|     pub start_date: Option<String>, |     pub start_date: Option<String>, | ||||||
|     pub tag_ids: Option<Vec<u64>>, |     pub tag_ids: Option<Vec<u64>>, | ||||||
|     pub task_ids: Option<Vec<u64>>, |     pub task_ids: Option<Vec<u64>>, | ||||||
| @ -114,7 +143,7 @@ pub struct TogglQuery { | |||||||
| 
 | 
 | ||||||
| use std::fmt; | use std::fmt; | ||||||
| 
 | 
 | ||||||
| impl fmt::Debug for TogglQuery { | impl fmt::Debug for TogglReportQuery { | ||||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||||
|         let mut ds = f.debug_struct("TogglQuery"); |         let mut ds = f.debug_struct("TogglQuery"); | ||||||
| 
 | 
 | ||||||
| @ -160,8 +189,8 @@ impl fmt::Debug for TogglQuery { | |||||||
|         if let Some(order_dir) = &self.order_dir { |         if let Some(order_dir) = &self.order_dir { | ||||||
|             ds.field("order_dir", order_dir); |             ds.field("order_dir", order_dir); | ||||||
|         } |         } | ||||||
|         if let Some(postedFields) = &self.postedFields { |         if let Some(posted_fields) = &self.posted_fields { | ||||||
|             ds.field("postedFields", postedFields); |             ds.field("postedFields", posted_fields); | ||||||
|         } |         } | ||||||
|         if let Some(project_ids) = &self.project_ids { |         if let Some(project_ids) = &self.project_ids { | ||||||
|             ds.field("project_ids", project_ids); |             ds.field("project_ids", project_ids); | ||||||
| @ -172,8 +201,8 @@ impl fmt::Debug for TogglQuery { | |||||||
|         if let Some(rounding_minutes) = &self.rounding_minutes { |         if let Some(rounding_minutes) = &self.rounding_minutes { | ||||||
|             ds.field("rounding_minutes", rounding_minutes); |             ds.field("rounding_minutes", rounding_minutes); | ||||||
|         } |         } | ||||||
|         if let Some(startTime) = &self.startTime { |         if let Some(start_time) = &self.start_time { | ||||||
|             ds.field("startTime", startTime); |             ds.field("startTime", start_time); | ||||||
|         } |         } | ||||||
|         if let Some(start_date) = &self.start_date { |         if let Some(start_date) = &self.start_date { | ||||||
|             ds.field("start_date", start_date); |             ds.field("start_date", start_date); | ||||||
							
								
								
									
										13
									
								
								src/utils.rs
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								src/utils.rs
									
									
									
									
									
								
							| @ -1,5 +1,9 @@ | |||||||
|  | use crate::entity::time_entry; | ||||||
| use axum::http::StatusCode; | use axum::http::StatusCode; | ||||||
| use axum::response::IntoResponse; | use axum::response::IntoResponse; | ||||||
|  | use chrono::{NaiveDate, NaiveTime}; | ||||||
|  | use migration::{Condition, IntoCondition}; | ||||||
|  | use sea_orm::ColumnTrait; | ||||||
| use tokio::signal; | use tokio::signal; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug)] | #[derive(Debug)] | ||||||
| @ -52,3 +56,12 @@ pub async fn shutdown_signal() { | |||||||
|         _ = terminate => {}, |         _ = terminate => {}, | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | pub fn day_exclusivity_condition(start: NaiveDate, end: NaiveDate) -> Condition { | ||||||
|  |     time_entry::Column::Start | ||||||
|  |         .between( | ||||||
|  |             start.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()), | ||||||
|  |             end.and_time(NaiveTime::from_hms_opt(23, 59, 59).unwrap()), | ||||||
|  |         ) | ||||||
|  |         .into_condition() | ||||||
|  | } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user