mirror of
https://github.com/nitnelave/lldap.git
synced 2023-04-12 14:25:13 +00:00
server: Add support for users' avatars in GrahpQL
This commit is contained in:
parent
0e3c5120da
commit
3acc448048
11
Cargo.lock
generated
11
Cargo.lock
generated
@ -2115,6 +2115,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hmac 0.10.1",
|
"hmac 0.10.1",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"itertools",
|
"itertools",
|
||||||
"juniper",
|
"juniper",
|
||||||
"juniper_actix",
|
"juniper_actix",
|
||||||
@ -2133,6 +2134,7 @@ dependencies = [
|
|||||||
"sea-query-binder",
|
"sea-query-binder",
|
||||||
"secstr",
|
"secstr",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_bytes",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@ -3317,6 +3319,15 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_bytes"
|
||||||
|
version = "0.11.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfc50e8183eeeb6178dcb167ae34a8051d63535023ae38b5d8d12beae193d37b"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.137"
|
version = "1.0.137"
|
||||||
|
@ -90,6 +90,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
displayName: to_option(model.display_name),
|
displayName: to_option(model.display_name),
|
||||||
firstName: to_option(model.first_name),
|
firstName: to_option(model.first_name),
|
||||||
lastName: to_option(model.last_name),
|
lastName: to_option(model.last_name),
|
||||||
|
avatar: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
|
@ -234,6 +234,7 @@ impl UserDetailsForm {
|
|||||||
displayName: None,
|
displayName: None,
|
||||||
firstName: None,
|
firstName: None,
|
||||||
lastName: None,
|
lastName: None,
|
||||||
|
avatar: None,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
|
@ -85,6 +85,7 @@ impl User {
|
|||||||
display_name,
|
display_name,
|
||||||
first_name,
|
first_name,
|
||||||
last_name,
|
last_name,
|
||||||
|
avatar: None,
|
||||||
},
|
},
|
||||||
password,
|
password,
|
||||||
dn,
|
dn,
|
||||||
|
@ -60,6 +60,7 @@ input CreateUserInput {
|
|||||||
displayName: String
|
displayName: String
|
||||||
firstName: String
|
firstName: String
|
||||||
lastName: String
|
lastName: String
|
||||||
|
avatar: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
type User {
|
||||||
@ -68,6 +69,7 @@ type User {
|
|||||||
displayName: String!
|
displayName: String!
|
||||||
firstName: String!
|
firstName: String!
|
||||||
lastName: String!
|
lastName: String!
|
||||||
|
avatar: String!
|
||||||
creationDate: DateTimeUtc!
|
creationDate: DateTimeUtc!
|
||||||
uuid: String!
|
uuid: String!
|
||||||
"The groups to which this user belongs."
|
"The groups to which this user belongs."
|
||||||
@ -85,6 +87,7 @@ input UpdateUserInput {
|
|||||||
displayName: String
|
displayName: String
|
||||||
firstName: String
|
firstName: String
|
||||||
lastName: String
|
lastName: String
|
||||||
|
avatar: String
|
||||||
}
|
}
|
||||||
|
|
||||||
schema {
|
schema {
|
||||||
|
@ -45,6 +45,7 @@ tracing-actix-web = "0.4.0-beta.7"
|
|||||||
tracing-attributes = "^0.1.21"
|
tracing-attributes = "^0.1.21"
|
||||||
tracing-log = "*"
|
tracing-log = "*"
|
||||||
rustls-pemfile = "1.0.0"
|
rustls-pemfile = "1.0.0"
|
||||||
|
serde_bytes = "0.11.7"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
features = ["serde"]
|
features = ["serde"]
|
||||||
@ -117,5 +118,10 @@ version = "^0.1.4"
|
|||||||
features = ["default", "rustls"]
|
features = ["default", "rustls"]
|
||||||
version = "=3.0.0-beta.5"
|
version = "=3.0.0-beta.5"
|
||||||
|
|
||||||
|
[dependencies.image]
|
||||||
|
features = ["jpeg"]
|
||||||
|
default-features = false
|
||||||
|
version = "0.24"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockall = "0.9.1"
|
mockall = "0.9.1"
|
||||||
|
@ -82,6 +82,69 @@ impl From<String> for UserId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec<u8>);
|
||||||
|
|
||||||
|
impl From<JpegPhoto> for sea_query::Value {
|
||||||
|
fn from(photo: JpegPhoto) -> Self {
|
||||||
|
photo.0.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&JpegPhoto> for sea_query::Value {
|
||||||
|
fn from(photo: &JpegPhoto) -> Self {
|
||||||
|
photo.0.as_slice().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Vec<u8>> for JpegPhoto {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
fn try_from(bytes: Vec<u8>) -> anyhow::Result<Self> {
|
||||||
|
// Confirm that it's a valid Jpeg, then store only the bytes.
|
||||||
|
image::io::Reader::with_format(
|
||||||
|
std::io::Cursor::new(bytes.as_slice()),
|
||||||
|
image::ImageFormat::Jpeg,
|
||||||
|
)
|
||||||
|
.decode()?;
|
||||||
|
Ok(JpegPhoto(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for JpegPhoto {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
fn try_from(string: String) -> anyhow::Result<Self> {
|
||||||
|
// The String format is in base64.
|
||||||
|
Self::try_from(base64::decode(string.as_str())?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&JpegPhoto> for String {
|
||||||
|
fn from(val: &JpegPhoto) -> Self {
|
||||||
|
base64::encode(&val.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JpegPhoto {
|
||||||
|
pub fn for_tests() -> Self {
|
||||||
|
use image::{ImageOutputFormat, Rgb, RgbImage};
|
||||||
|
let img = RgbImage::from_fn(32, 32, |x, y| {
|
||||||
|
if (x + y) % 2 == 0 {
|
||||||
|
Rgb([0, 0, 0])
|
||||||
|
} else {
|
||||||
|
Rgb([255, 255, 255])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
img.write_to(
|
||||||
|
&mut std::io::Cursor::new(&mut bytes),
|
||||||
|
ImageOutputFormat::Jpeg(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
@ -89,7 +152,7 @@ pub struct User {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
// pub avatar: ?,
|
pub avatar: JpegPhoto,
|
||||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
}
|
}
|
||||||
@ -105,6 +168,7 @@ impl Default for User {
|
|||||||
display_name: String::new(),
|
display_name: String::new(),
|
||||||
first_name: String::new(),
|
first_name: String::new(),
|
||||||
last_name: String::new(),
|
last_name: String::new(),
|
||||||
|
avatar: JpegPhoto::default(),
|
||||||
creation_date: epoch,
|
creation_date: epoch,
|
||||||
uuid: Uuid::from_name_and_date("", &epoch),
|
uuid: Uuid::from_name_and_date("", &epoch),
|
||||||
}
|
}
|
||||||
@ -159,6 +223,7 @@ pub struct CreateUserRequest {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub first_name: Option<String>,
|
pub first_name: Option<String>,
|
||||||
pub last_name: Option<String>,
|
pub last_name: Option<String>,
|
||||||
|
pub avatar: Option<JpegPhoto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
@ -169,6 +234,7 @@ pub struct UpdateUserRequest {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub first_name: Option<String>,
|
pub first_name: Option<String>,
|
||||||
pub last_name: Option<String>,
|
pub last_name: Option<String>,
|
||||||
|
pub avatar: Option<JpegPhoto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||||
@ -263,4 +329,11 @@ mod tests {
|
|||||||
Uuid::from_name_and_date(user_id, &date2)
|
Uuid::from_name_and_date(user_id, &date2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jpeg_try_from_bytes() {
|
||||||
|
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
|
||||||
|
let base64_jpeg = base64::decode(base64_raw).unwrap();
|
||||||
|
JpegPhoto::try_from(base64_jpeg).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -367,6 +367,7 @@ impl BackendHandler for SqlBackendHandler {
|
|||||||
Users::DisplayName,
|
Users::DisplayName,
|
||||||
Users::FirstName,
|
Users::FirstName,
|
||||||
Users::LastName,
|
Users::LastName,
|
||||||
|
Users::Avatar,
|
||||||
Users::CreationDate,
|
Users::CreationDate,
|
||||||
Users::Uuid,
|
Users::Uuid,
|
||||||
];
|
];
|
||||||
@ -378,6 +379,7 @@ impl BackendHandler for SqlBackendHandler {
|
|||||||
request.display_name.unwrap_or_default().into(),
|
request.display_name.unwrap_or_default().into(),
|
||||||
request.first_name.unwrap_or_default().into(),
|
request.first_name.unwrap_or_default().into(),
|
||||||
request.last_name.unwrap_or_default().into(),
|
request.last_name.unwrap_or_default().into(),
|
||||||
|
request.avatar.unwrap_or_default().into(),
|
||||||
now.naive_utc().into(),
|
now.naive_utc().into(),
|
||||||
uuid.into(),
|
uuid.into(),
|
||||||
];
|
];
|
||||||
@ -409,6 +411,9 @@ impl BackendHandler for SqlBackendHandler {
|
|||||||
if let Some(last_name) = request.last_name {
|
if let Some(last_name) = request.last_name {
|
||||||
values.push((Users::LastName, last_name.into()));
|
values.push((Users::LastName, last_name.into()));
|
||||||
}
|
}
|
||||||
|
if let Some(avatar) = request.avatar {
|
||||||
|
values.push((Users::Avatar, avatar.into()));
|
||||||
|
}
|
||||||
if values.is_empty() {
|
if values.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
use crate::domain::handler::{
|
use crate::domain::handler::{
|
||||||
BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest, UserId,
|
BackendHandler, CreateUserRequest, GroupId, JpegPhoto, UpdateGroupRequest, UpdateUserRequest,
|
||||||
|
UserId,
|
||||||
};
|
};
|
||||||
|
use anyhow::Context as AnyhowContext;
|
||||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
|
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
|
||||||
use tracing::{debug, debug_span, Instrument};
|
use tracing::{debug, debug_span, Instrument};
|
||||||
|
|
||||||
@ -28,6 +30,8 @@ pub struct CreateUserInput {
|
|||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
first_name: Option<String>,
|
first_name: Option<String>,
|
||||||
last_name: Option<String>,
|
last_name: Option<String>,
|
||||||
|
// Base64 encoded JpegPhoto.
|
||||||
|
avatar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
|
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
|
||||||
@ -38,6 +42,8 @@ pub struct UpdateUserInput {
|
|||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
first_name: Option<String>,
|
first_name: Option<String>,
|
||||||
last_name: Option<String>,
|
last_name: Option<String>,
|
||||||
|
// Base64 encoded JpegPhoto.
|
||||||
|
avatar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
|
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
|
||||||
@ -73,6 +79,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
|||||||
return Err("Unauthorized user creation".into());
|
return Err("Unauthorized user creation".into());
|
||||||
}
|
}
|
||||||
let user_id = UserId::new(&user.id);
|
let user_id = UserId::new(&user.id);
|
||||||
|
let avatar = user
|
||||||
|
.avatar
|
||||||
|
.map(base64::decode)
|
||||||
|
.transpose()
|
||||||
|
.context("Invalid base64 image")?
|
||||||
|
.map(JpegPhoto::try_from)
|
||||||
|
.transpose()
|
||||||
|
.context("Provided image is not a valid JPEG")?;
|
||||||
context
|
context
|
||||||
.handler
|
.handler
|
||||||
.create_user(CreateUserRequest {
|
.create_user(CreateUserRequest {
|
||||||
@ -81,6 +95,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
|||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
first_name: user.first_name,
|
first_name: user.first_name,
|
||||||
last_name: user.last_name,
|
last_name: user.last_name,
|
||||||
|
avatar,
|
||||||
})
|
})
|
||||||
.instrument(span.clone())
|
.instrument(span.clone())
|
||||||
.await?;
|
.await?;
|
||||||
@ -126,6 +141,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
|||||||
span.in_scope(|| debug!("Unauthorized"));
|
span.in_scope(|| debug!("Unauthorized"));
|
||||||
return Err("Unauthorized user update".into());
|
return Err("Unauthorized user update".into());
|
||||||
}
|
}
|
||||||
|
let avatar = user
|
||||||
|
.avatar
|
||||||
|
.map(base64::decode)
|
||||||
|
.transpose()
|
||||||
|
.context("Invalid base64 image")?
|
||||||
|
.map(JpegPhoto::try_from)
|
||||||
|
.transpose()
|
||||||
|
.context("Provided image is not a valid JPEG")?;
|
||||||
context
|
context
|
||||||
.handler
|
.handler
|
||||||
.update_user(UpdateUserRequest {
|
.update_user(UpdateUserRequest {
|
||||||
@ -134,6 +157,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
|||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
first_name: user.first_name,
|
first_name: user.first_name,
|
||||||
last_name: user.last_name,
|
last_name: user.last_name,
|
||||||
|
avatar,
|
||||||
})
|
})
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -217,6 +217,10 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
|
|||||||
&self.user.last_name
|
&self.user.last_name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn avatar(&self) -> String {
|
||||||
|
(&self.user.avatar).into()
|
||||||
|
}
|
||||||
|
|
||||||
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
||||||
self.user.creation_date
|
self.user.creation_date
|
||||||
}
|
}
|
||||||
|
@ -1459,6 +1459,7 @@ mod tests {
|
|||||||
display_name: "Jimminy Cricket".to_string(),
|
display_name: "Jimminy Cricket".to_string(),
|
||||||
first_name: "Jim".to_string(),
|
first_name: "Jim".to_string(),
|
||||||
last_name: "Cricket".to_string(),
|
last_name: "Cricket".to_string(),
|
||||||
|
avatar: JpegPhoto::for_tests(),
|
||||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
||||||
creation_date: Utc.ymd(2014, 7, 8).and_hms(9, 10, 11),
|
creation_date: Utc.ymd(2014, 7, 8).and_hms(9, 10, 11),
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user