features, cleanup and bug fixes #26
13 changed files with 301 additions and 111 deletions
more edit dialog allow addition/deletion of attachments
* form now also shows emty slots for links/attachments when the default form count is not already filled
commit
62c6b8170d
|
|
@ -114,7 +114,7 @@ pub(crate) async fn tmpfile_from_field(
|
||||||
limit: usize,
|
limit: usize,
|
||||||
tmp_dir: &Path,
|
tmp_dir: &Path,
|
||||||
) -> Result<(String, Option<NamedTempFile>), MultipartFieldError> {
|
) -> Result<(String, Option<NamedTempFile>), MultipartFieldError> {
|
||||||
// while it is technically valid to not get a filename here, we expect it to be present
|
// while it is technically valid to not get a filename, we expect it to be present
|
||||||
let file_name = field
|
let file_name = field
|
||||||
.content_disposition()
|
.content_disposition()
|
||||||
.get_filename()
|
.get_filename()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::collections::BTreeMap;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::ops::{Add, Deref, DerefMut};
|
use std::ops::{Add, Deref, DerefMut};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use actix_web::error::UrlGenerationError;
|
use actix_web::error::UrlGenerationError;
|
||||||
use actix_web::{HttpRequest, Result};
|
use actix_web::{HttpRequest, Result};
|
||||||
|
|
@ -13,7 +13,7 @@ use error::DeleteError;
|
||||||
use lettre::Address;
|
use lettre::Address;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use rand::distributions::DistString;
|
use rand::distributions::DistString;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::{NamedTempFile, PersistError};
|
||||||
use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use view::{JobOfferActions, JobOfferViewData};
|
use view::{JobOfferActions, JobOfferViewData};
|
||||||
|
|
@ -63,6 +63,26 @@ pub(crate) struct Attachment<Location> {
|
||||||
pub(crate) attachment_location: Location,
|
pub(crate) attachment_location: Location,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Attachment<NamedTempFile> {
|
||||||
|
pub(crate) fn persist(
|
||||||
|
self,
|
||||||
|
idx: usize,
|
||||||
|
folder_path: &Path,
|
||||||
|
) -> Result<Attachment<PathBuf>, PersistError> {
|
||||||
|
let attachment_location = Self::attachment_filename(idx);
|
||||||
|
let file_path = folder_path.join(&attachment_location);
|
||||||
|
self.attachment_location.persist(&file_path)?;
|
||||||
|
Ok(Attachment {
|
||||||
|
title: self.title,
|
||||||
|
file_name: self.file_name,
|
||||||
|
attachment_location,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn attachment_filename(idx: usize) -> PathBuf {
|
||||||
|
PathBuf::from(format!("{idx}.attachment"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Attachment<PathBuf> {
|
impl Attachment<PathBuf> {
|
||||||
pub(crate) fn generate_link(
|
pub(crate) fn generate_link(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -208,7 +228,7 @@ impl<A> JobOffer<A> {
|
||||||
self.status.is_published()
|
self.status.is_published()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn folder_path(id: &JobOfferId, config: &ServerConfig) -> PathBuf {
|
pub(crate) fn folder_path(id: &JobOfferId, config: &ServerConfig) -> PathBuf {
|
||||||
config.config.data_storage_path.join(id)
|
config.config.data_storage_path.join(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,16 +252,7 @@ impl JobOffer<NamedTempFile> {
|
||||||
.attachments
|
.attachments
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, entry)| {
|
.map(|(idx, entry)| entry.persist(idx, &folder_path))
|
||||||
let attachment_location = PathBuf::from(format!("{idx}.attachment"));
|
|
||||||
let file_path = folder_path.join(&attachment_location);
|
|
||||||
entry.attachment_location.persist(&file_path)?;
|
|
||||||
Ok(Attachment {
|
|
||||||
title: entry.title,
|
|
||||||
file_name: entry.file_name,
|
|
||||||
attachment_location,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<_, tempfile::PersistError>>()?;
|
.collect::<Result<_, tempfile::PersistError>>()?;
|
||||||
|
|
||||||
// use https://github.com/rust-lang/rust/issues/86555 when stabilized
|
// use https://github.com/rust-lang/rust/issues/86555 when stabilized
|
||||||
|
|
@ -287,7 +298,6 @@ impl JobOffer<PathBuf> {
|
||||||
let offer = match toml::ser::to_string(self) {
|
let offer = match toml::ser::to_string(self) {
|
||||||
Ok(offer) => offer,
|
Ok(offer) => offer,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
dbg!(&err);
|
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -346,8 +356,6 @@ impl JobOffer<PathBuf> {
|
||||||
JobOffer::<PathBuf>::hash(self, &mut hasher);
|
JobOffer::<PathBuf>::hash(self, &mut hasher);
|
||||||
let hash = hasher.finish();
|
let hash = hasher.finish();
|
||||||
|
|
||||||
dbg!(hash);
|
|
||||||
|
|
||||||
let attachments = self
|
let attachments = self
|
||||||
.attachments
|
.attachments
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -360,6 +368,12 @@ impl JobOffer<PathBuf> {
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, UrlGenerationError>>()?;
|
.collect::<Result<Vec<_>, UrlGenerationError>>()?;
|
||||||
|
|
||||||
|
let mut links: Vec<_> = self.links.iter().cloned().map(Some).collect();
|
||||||
|
|
||||||
|
if links.len() < crate::route::form_constants::LINK_FIELD_COUNT {
|
||||||
|
links.resize(crate::route::form_constants::LINK_FIELD_COUNT, None)
|
||||||
|
}
|
||||||
|
|
||||||
Ok(JobOfferEditData {
|
Ok(JobOfferEditData {
|
||||||
id: id.to_owned(),
|
id: id.to_owned(),
|
||||||
hash,
|
hash,
|
||||||
|
|
@ -371,7 +385,7 @@ impl JobOffer<PathBuf> {
|
||||||
submission_date: self.date_of_submission.to_string(),
|
submission_date: self.date_of_submission.to_string(),
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
attachments,
|
attachments,
|
||||||
links: self.links.clone(),
|
links,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ pub(crate) struct JobOfferEditData {
|
||||||
pub(crate) submission_date: String,
|
pub(crate) submission_date: String,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) attachments: Vec<Attachment<SerializableUrl>>,
|
pub(crate) attachments: Vec<Attachment<SerializableUrl>>,
|
||||||
pub(crate) links: Vec<Link>,
|
pub(crate) links: Vec<Option<Link>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use url::Url;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
pub(crate) mod error_handler;
|
pub(crate) mod error_handler;
|
||||||
|
pub(crate) mod form_constants;
|
||||||
mod job_offer;
|
mod job_offer;
|
||||||
mod license;
|
mod license;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ pub(crate) const PRE_APPROVED: &str = "pre_approved";
|
||||||
pub(crate) const SKIP_CONFIRMATION: &str = "skip_confirmation";
|
pub(crate) const SKIP_CONFIRMATION: &str = "skip_confirmation";
|
||||||
pub(crate) const PERMANENT_FIELD: &str = "permanent";
|
pub(crate) const PERMANENT_FIELD: &str = "permanent";
|
||||||
pub(crate) const ATTACHMENT_TITLES: &str = "file_title[]";
|
pub(crate) const ATTACHMENT_TITLES: &str = "file_title[]";
|
||||||
pub(crate) const ATTACHMENT_FILE_NAME: &str = "file_name[]";
|
|
||||||
pub(crate) const ATTACHMENT_FILES: &str = "file[]";
|
pub(crate) const ATTACHMENT_FILES: &str = "file[]";
|
||||||
pub(crate) const LINK_TITLES: &str = "link_title[]";
|
pub(crate) const LINK_TITLES: &str = "link_title[]";
|
||||||
pub(crate) const LINK_URLS: &str = "link_url[]";
|
pub(crate) const LINK_URLS: &str = "link_url[]";
|
||||||
|
|
@ -43,6 +42,9 @@ pub(crate) const MAX_LINK_URL_LEN: usize = 2048;
|
||||||
pub(crate) const MAX_ATTACHMENT_TITLE_LEN: usize = 128;
|
pub(crate) const MAX_ATTACHMENT_TITLE_LEN: usize = 128;
|
||||||
pub(crate) const MAX_LINK_COUNT: Option<u8> = Some(20);
|
pub(crate) const MAX_LINK_COUNT: Option<u8> = Some(20);
|
||||||
|
|
||||||
|
pub(crate) const ATTACHMENT_FIELD_COUNT: usize = 4;
|
||||||
|
pub(crate) const LINK_FIELD_COUNT: usize = 4;
|
||||||
|
|
||||||
pub(crate) struct UploadLimits {
|
pub(crate) struct UploadLimits {
|
||||||
pub(crate) size: usize,
|
pub(crate) size: usize,
|
||||||
pub(crate) count: Option<u8>,
|
pub(crate) count: Option<u8>,
|
||||||
|
|
@ -63,3 +65,9 @@ pub(crate) const fn upload_limits(user: Option<&User>) -> UploadLimits {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) const HASH: &str = "hash";
|
pub(crate) const HASH: &str = "hash";
|
||||||
|
|
||||||
|
pub(crate) const ATTACHMENT_FILENAME_EDIT_FIELD: &str = "file_name_edit[]";
|
||||||
|
pub(crate) const ATTACHMENT_TITLE_EDIT_FIELD: &str = "file_title_edit[]";
|
||||||
|
pub(crate) const ATTACHMENT_FILE_REPLACE_FIELD: &str = "file_replace[]";
|
||||||
|
|
||||||
|
pub(crate) const DELETE_ATTACHMENT_FIELD: &'static str = "delete_attachment[]";
|
||||||
|
|
@ -14,7 +14,6 @@ pub(crate) mod create;
|
||||||
pub(crate) mod delete;
|
pub(crate) mod delete;
|
||||||
pub(crate) mod edit;
|
pub(crate) mod edit;
|
||||||
pub(crate) mod error;
|
pub(crate) mod error;
|
||||||
pub(crate) mod form_constants;
|
|
||||||
pub(crate) mod review;
|
pub(crate) mod review;
|
||||||
|
|
||||||
use crate::auth::User;
|
use crate::auth::User;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use lettre::message::{Mailbox, SinglePart};
|
||||||
use lettre::{Address, AsyncTransport};
|
use lettre::{Address, AsyncTransport};
|
||||||
use log::{debug, error, warn};
|
use log::{debug, error, warn};
|
||||||
use rand::distributions::DistString;
|
use rand::distributions::DistString;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
@ -23,17 +24,23 @@ use crate::job_offers::{
|
||||||
Attachment, ConfirmationStatus, JobOffer, JobOfferStatus, JobOffers, Link, MutBorrowedJobOffer,
|
Attachment, ConfirmationStatus, JobOffer, JobOfferStatus, JobOffers, Link, MutBorrowedJobOffer,
|
||||||
ReviewStatus,
|
ReviewStatus,
|
||||||
};
|
};
|
||||||
|
use crate::route::form_constants::{self, UploadLimits};
|
||||||
use crate::route::job_offer::confirmation::JOBOFFER_CONFIRM_ROUTE;
|
use crate::route::job_offer::confirmation::JOBOFFER_CONFIRM_ROUTE;
|
||||||
use crate::route::job_offer::error::{FormProcessingError, SubmissionResponseError};
|
use crate::route::job_offer::error::{FormProcessingError, SubmissionResponseError};
|
||||||
use crate::route::job_offer::form_constants::{self, UploadLimits};
|
|
||||||
use crate::route::HTML_CONTENT;
|
use crate::route::HTML_CONTENT;
|
||||||
use crate::server_config::{EmailConfig, ServerConfig};
|
use crate::server_config::{EmailConfig, ServerConfig};
|
||||||
use crate::template;
|
use crate::template;
|
||||||
use crate::util::{parse_date, parse_datetime, process_links};
|
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
|
||||||
use multipart_helper::{multi_field, multi_file, once_field};
|
use multipart_helper::{multi_field, multi_file, once_field};
|
||||||
|
|
||||||
pub(crate) const JOBOFFER_CREATION_ROUTE: &str = "create_offer";
|
pub(crate) const JOBOFFER_CREATION_ROUTE: &str = "create_offer";
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SubmissionFormRenderData {
|
||||||
|
attachments: Vec<()>,
|
||||||
|
links: Vec<()>,
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/new", name = "create_offer")]
|
#[get("/new", name = "create_offer")]
|
||||||
pub(crate) async fn create_joboffer_get(
|
pub(crate) async fn create_joboffer_get(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
|
|
@ -43,9 +50,15 @@ pub(crate) async fn create_joboffer_get(
|
||||||
) -> Result<HttpResponse, PresentationError> {
|
) -> Result<HttpResponse, PresentationError> {
|
||||||
let user = User::current(&session).ok();
|
let user = User::current(&session).ok();
|
||||||
|
|
||||||
|
let form_data = SubmissionFormRenderData {
|
||||||
|
attachments: vec![(); form_constants::ATTACHMENT_FIELD_COUNT],
|
||||||
|
links: vec![(); form_constants::LINK_FIELD_COUNT],
|
||||||
|
};
|
||||||
|
|
||||||
let data = json!({
|
let data = json!({
|
||||||
"base": crate::route::base(&req, &config,"Create Joboffer")?,
|
"base": crate::route::base(&req, &config,"Create Joboffer")?,
|
||||||
"user": user,
|
"user": user,
|
||||||
|
"form": form_data,
|
||||||
});
|
});
|
||||||
|
|
||||||
let rendered = hb.render(template::JOBOFFER_CREATE, &data)?;
|
let rendered = hb.render(template::JOBOFFER_CREATE, &data)?;
|
||||||
|
|
@ -469,26 +482,7 @@ impl JobOfferSubmitForm {
|
||||||
|
|
||||||
assert_eq!(attachment_titles.len(), attachment_datas.len());
|
assert_eq!(attachment_titles.len(), attachment_datas.len());
|
||||||
|
|
||||||
let attachments = attachment_titles
|
let attachments = process_new_attachments(attachment_titles, attachment_datas);
|
||||||
.into_iter()
|
|
||||||
.zip(attachment_datas.into_iter())
|
|
||||||
.filter_map(|(title, (file_name, attachment_location))| {
|
|
||||||
attachment_location
|
|
||||||
.map(|attachment_location| (title, file_name, attachment_location))
|
|
||||||
})
|
|
||||||
.enumerate()
|
|
||||||
.map(
|
|
||||||
|(idx, (title, file_name, attachment_location))| Attachment {
|
|
||||||
title: if title.is_empty() {
|
|
||||||
format!("Attachment {}: {}", idx + 1, file_name)
|
|
||||||
} else {
|
|
||||||
title
|
|
||||||
},
|
|
||||||
file_name,
|
|
||||||
attachment_location,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(link_titles.len(), link_urls.len());
|
assert_eq!(link_titles.len(), link_urls.len());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ use crate::auth::User;
|
||||||
use crate::error::{LoginRequired, PresentationError};
|
use crate::error::{LoginRequired, PresentationError};
|
||||||
use crate::job_offers::error::SaveError;
|
use crate::job_offers::error::SaveError;
|
||||||
use crate::job_offers::{Attachment, JobOffer, Link};
|
use crate::job_offers::{Attachment, JobOffer, Link};
|
||||||
|
use crate::route::form_constants::{self, UploadLimits};
|
||||||
use crate::route::job_offer::error::FormProcessingError;
|
use crate::route::job_offer::error::FormProcessingError;
|
||||||
use crate::route::job_offer::form_constants;
|
|
||||||
use crate::route::job_offer::form_constants::UploadLimits;
|
|
||||||
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
|
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
|
||||||
use crate::util::{parse_date, parse_datetime, process_links};
|
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
|
||||||
use crate::{template, JobOffers, ServerConfig};
|
use crate::{template, JobOffers, ServerConfig};
|
||||||
use actix_multipart::Multipart;
|
use actix_multipart::Multipart;
|
||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
|
|
@ -16,11 +15,12 @@ use futures_util::StreamExt;
|
||||||
use handlebars::Handlebars;
|
use handlebars::Handlebars;
|
||||||
use lettre::Address;
|
use lettre::Address;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use multipart_helper::{multi_field, once_field};
|
use multipart_helper::{multi_field, multi_file, once_field};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::Hasher;
|
use std::hash::Hasher;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub(crate) enum EditResponseError {
|
pub(crate) enum EditResponseError {
|
||||||
|
|
@ -63,9 +63,15 @@ pub(crate) async fn edit_joboffer_get(
|
||||||
let base =
|
let base =
|
||||||
crate::route::base(&req, &config, "Edit Job Offer").map_err(PresentationError::Url)?;
|
crate::route::base(&req, &config, "Edit Job Offer").map_err(PresentationError::Url)?;
|
||||||
|
|
||||||
|
let additional_slots =
|
||||||
|
form_constants::ATTACHMENT_FIELD_COUNT.saturating_sub(job_offer.attachments.len());
|
||||||
|
|
||||||
let data = json!({
|
let data = json!({
|
||||||
"base": base,
|
"base": base,
|
||||||
"job_offer": job_offer
|
"job_offer": job_offer,
|
||||||
|
"form" : {
|
||||||
|
"remaining_attachments": vec![(); additional_slots]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let body = hb
|
let body = hb
|
||||||
|
|
@ -100,8 +106,6 @@ pub(crate) async fn edit_joboffer_post(
|
||||||
JobOffer::<PathBuf>::hash(&offer, &mut orig_hasher);
|
JobOffer::<PathBuf>::hash(&offer, &mut orig_hasher);
|
||||||
let orig_hash = orig_hasher.finish();
|
let orig_hash = orig_hasher.finish();
|
||||||
|
|
||||||
dbg!(orig_hash);
|
|
||||||
|
|
||||||
if orig_hash != form_data.hash {
|
if orig_hash != form_data.hash {
|
||||||
return Err(EditResponseError::ConflictingChange);
|
return Err(EditResponseError::ConflictingChange);
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +115,7 @@ pub(crate) async fn edit_joboffer_post(
|
||||||
offer_mut_ref.title = form_data.title;
|
offer_mut_ref.title = form_data.title;
|
||||||
offer_mut_ref.permanent = form_data.permanent;
|
offer_mut_ref.permanent = form_data.permanent;
|
||||||
offer_mut_ref.offering_party = form_data.offering_party;
|
offer_mut_ref.offering_party = form_data.offering_party;
|
||||||
|
offer_mut_ref.contact_info = form_data.contact_data;
|
||||||
offer_mut_ref.public_contact_info = form_data.public_contact_data;
|
offer_mut_ref.public_contact_info = form_data.public_contact_data;
|
||||||
|
|
||||||
if let Some(back_date) = form_data.backdate {
|
if let Some(back_date) = form_data.backdate {
|
||||||
|
|
@ -121,16 +126,66 @@ pub(crate) async fn edit_joboffer_post(
|
||||||
offer_mut_ref.links = form_data.links;
|
offer_mut_ref.links = form_data.links;
|
||||||
|
|
||||||
// currently adding/removing attachments is not supported
|
// currently adding/removing attachments is not supported
|
||||||
assert_eq!(offer_mut_ref.attachments.len(), form_data.attachments.len());
|
assert_eq!(
|
||||||
|
offer_mut_ref.attachments.len(),
|
||||||
|
form_data.attachment_edits.len()
|
||||||
|
);
|
||||||
|
|
||||||
for (offer, form) in offer_mut_ref
|
let job_offer_folder = JobOffer::<PathBuf>::folder_path(id, &config);
|
||||||
|
|
||||||
|
let tmp = std::mem::take(&mut offer_mut_ref.attachments)
|
||||||
|
.into_iter()
|
||||||
|
.zip(form_data.attachment_edits)
|
||||||
|
.filter_map(|(mut offer, edit)| match edit {
|
||||||
|
AttachmentEdit::Delete => {
|
||||||
|
// TODO error handling
|
||||||
|
let _todo = std::fs::remove_file(job_offer_folder.join(offer.attachment_location));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
AttachmentEdit::Edit {
|
||||||
|
title,
|
||||||
|
file_name,
|
||||||
|
file,
|
||||||
|
} => {
|
||||||
|
offer.title = title;
|
||||||
|
offer.file_name = file_name;
|
||||||
|
|
||||||
|
if let Some(tmp_file) = file {
|
||||||
|
// TODO error handling
|
||||||
|
let _todo =
|
||||||
|
tmp_file.persist(&job_offer_folder.join(&offer.attachment_location));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(offer)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
offer_mut_ref.attachments.extend(tmp);
|
||||||
|
|
||||||
|
let existing_files: Vec<_> = offer_mut_ref
|
||||||
.attachments
|
.attachments
|
||||||
.iter_mut()
|
.iter()
|
||||||
.zip(form_data.attachments)
|
.map(|attachment| attachment.attachment_location.clone())
|
||||||
{
|
.collect();
|
||||||
offer.title = form.title;
|
|
||||||
offer.file_name = form.file_name;
|
let mut idx = 0;
|
||||||
}
|
|
||||||
|
let mut new_attachments = form_data
|
||||||
|
.attachments_new
|
||||||
|
.into_iter()
|
||||||
|
.map(|attachment| {
|
||||||
|
// deleting attachment might mean the attachment at index 0 uses the file 2.attachment instead of 0.attachment
|
||||||
|
// so we cannot simply use the index this will get inserted at for the file name as that might already be in use
|
||||||
|
while existing_files.contains(&Attachment::attachment_filename(idx)) {
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
attachment
|
||||||
|
.persist(idx, &job_offer_folder)
|
||||||
|
.map_err(SaveError::Persist)
|
||||||
|
})
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
offer_mut_ref.attachments.append(&mut new_attachments);
|
||||||
|
|
||||||
offer.try_clean().await?;
|
offer.try_clean().await?;
|
||||||
|
|
||||||
|
|
@ -143,7 +198,6 @@ pub(crate) async fn edit_joboffer_post(
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub(crate) struct JobOfferEditForm {
|
pub(crate) struct JobOfferEditForm {
|
||||||
pub(crate) hash: u64,
|
pub(crate) hash: u64,
|
||||||
pub(crate) offering_party: String,
|
pub(crate) offering_party: String,
|
||||||
|
|
@ -153,17 +207,27 @@ pub(crate) struct JobOfferEditForm {
|
||||||
pub(crate) expiry_date: Option<Date>,
|
pub(crate) expiry_date: Option<Date>,
|
||||||
pub(crate) backdate: Option<Datetime>,
|
pub(crate) backdate: Option<Datetime>,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) attachments: Vec<Attachment<()>>,
|
pub(crate) attachment_edits: Vec<AttachmentEdit>,
|
||||||
|
pub(crate) attachments_new: Vec<Attachment<NamedTempFile>>,
|
||||||
pub(crate) links: Vec<Link>,
|
pub(crate) links: Vec<Link>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum AttachmentEdit {
|
||||||
|
Delete,
|
||||||
|
Edit {
|
||||||
|
title: String,
|
||||||
|
file_name: String,
|
||||||
|
file: Option<NamedTempFile>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
impl JobOfferEditForm {
|
impl JobOfferEditForm {
|
||||||
/// Convert the Multipart struct representing multipart form-data
|
/// Convert the Multipart struct representing multipart form-data
|
||||||
/// into structured form data
|
/// into structured form data
|
||||||
async fn from_multipart_form(
|
async fn from_multipart_form(
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
user: Option<&User>,
|
user: Option<&User>,
|
||||||
_config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
) -> Result<Self, FormProcessingError> {
|
) -> Result<Self, FormProcessingError> {
|
||||||
let mut hash = None;
|
let mut hash = None;
|
||||||
let mut offering_party = None;
|
let mut offering_party = None;
|
||||||
|
|
@ -174,14 +238,20 @@ impl JobOfferEditForm {
|
||||||
let mut back_date = None;
|
let mut back_date = None;
|
||||||
let mut expiry_date = None;
|
let mut expiry_date = None;
|
||||||
|
|
||||||
let mut attachment_titles = Vec::new();
|
let mut attachment_title_edits = Vec::new();
|
||||||
let mut attachment_filenames = Vec::new();
|
let mut attachment_filename_edits = Vec::new();
|
||||||
|
let mut attachment_file_replace = Vec::new();
|
||||||
|
|
||||||
|
let mut delete_attachment = Vec::new();
|
||||||
|
|
||||||
|
let mut attachment_titles_new = Vec::new();
|
||||||
|
let mut attachment_files_new = Vec::new();
|
||||||
|
|
||||||
let mut link_titles = Vec::new();
|
let mut link_titles = Vec::new();
|
||||||
let mut link_urls = Vec::new();
|
let mut link_urls = Vec::new();
|
||||||
|
|
||||||
let UploadLimits {
|
let UploadLimits {
|
||||||
size: _upload_size_limit,
|
size: upload_size_limit,
|
||||||
count: upload_count_limit,
|
count: upload_count_limit,
|
||||||
} = form_constants::upload_limits(user);
|
} = form_constants::upload_limits(user);
|
||||||
|
|
||||||
|
|
@ -242,28 +312,70 @@ impl JobOfferEditForm {
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
form_constants::ATTACHMENT_TITLES => {
|
form_constants::ATTACHMENT_TITLE_EDIT_FIELD => {
|
||||||
multi_field(
|
multi_field(
|
||||||
field,
|
field,
|
||||||
form_constants::ATTACHMENT_TITLES,
|
form_constants::ATTACHMENT_TITLE_EDIT_FIELD,
|
||||||
&mut attachment_titles,
|
&mut attachment_title_edits,
|
||||||
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
||||||
upload_count_limit,
|
upload_count_limit,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
form_constants::ATTACHMENT_FILE_NAME => {
|
form_constants::ATTACHMENT_FILENAME_EDIT_FIELD => {
|
||||||
multi_field(
|
multi_field(
|
||||||
field,
|
field,
|
||||||
form_constants::ATTACHMENT_FILE_NAME,
|
form_constants::ATTACHMENT_FILENAME_EDIT_FIELD,
|
||||||
&mut attachment_filenames,
|
&mut attachment_filename_edits,
|
||||||
// the submission form does not enforce a limite here as there we get it via a header and
|
// the submission form does not enforce a limite here as there we get it via a header and
|
||||||
// that is already in memory when we could check
|
// that is already in memory when we could perform the check
|
||||||
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
||||||
upload_count_limit,
|
upload_count_limit,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
form_constants::ATTACHMENT_FILE_REPLACE_FIELD => {
|
||||||
|
multi_file(
|
||||||
|
field,
|
||||||
|
form_constants::ATTACHMENT_FILE_REPLACE_FIELD,
|
||||||
|
&mut attachment_file_replace,
|
||||||
|
upload_size_limit,
|
||||||
|
upload_count_limit,
|
||||||
|
&config.config.data_storage_path,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
form_constants::DELETE_ATTACHMENT_FIELD => {
|
||||||
|
multi_field(
|
||||||
|
field,
|
||||||
|
form_constants::DELETE_ATTACHMENT_FIELD,
|
||||||
|
&mut delete_attachment,
|
||||||
|
2,
|
||||||
|
upload_count_limit,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
form_constants::ATTACHMENT_TITLES => {
|
||||||
|
multi_field(
|
||||||
|
field,
|
||||||
|
form_constants::ATTACHMENT_TITLES,
|
||||||
|
&mut attachment_titles_new,
|
||||||
|
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
||||||
|
upload_count_limit,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
form_constants::ATTACHMENT_FILES => {
|
||||||
|
multi_file(
|
||||||
|
field,
|
||||||
|
form_constants::ATTACHMENT_TITLES,
|
||||||
|
&mut attachment_files_new,
|
||||||
|
upload_size_limit,
|
||||||
|
upload_count_limit,
|
||||||
|
&config.config.data_storage_path,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
form_constants::LINK_TITLES => {
|
form_constants::LINK_TITLES => {
|
||||||
multi_field(
|
multi_field(
|
||||||
field,
|
field,
|
||||||
|
|
@ -315,20 +427,48 @@ impl JobOfferEditForm {
|
||||||
let expiry_date = parse_date(expiry_date.as_deref())?;
|
let expiry_date = parse_date(expiry_date.as_deref())?;
|
||||||
let backdate = parse_datetime(back_date.as_deref())?;
|
let backdate = parse_datetime(back_date.as_deref())?;
|
||||||
|
|
||||||
assert_eq!(attachment_filenames.len(), attachment_titles.len());
|
assert_eq!(
|
||||||
|
attachment_filename_edits.len(),
|
||||||
|
attachment_title_edits.len()
|
||||||
|
);
|
||||||
|
// if not set we should still get a field with empty content
|
||||||
|
assert_eq!(attachment_title_edits.len(), attachment_file_replace.len());
|
||||||
|
// we only get a value if delete is checked
|
||||||
|
assert!(delete_attachment.len() <= attachment_filename_edits.len());
|
||||||
|
|
||||||
let attachments = attachment_titles
|
let attachment_edits = attachment_title_edits
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(attachment_filenames.into_iter())
|
.zip(attachment_filename_edits.into_iter())
|
||||||
.filter_map(|(title, file_name)| {
|
.zip(attachment_file_replace.into_iter())
|
||||||
Some(Attachment {
|
.enumerate()
|
||||||
title,
|
.map(
|
||||||
file_name,
|
|(idx, ((title, specified_file_name), (upload_file_name, file)))| {
|
||||||
attachment_location: (),
|
if delete_attachment.contains(&idx.to_string()) {
|
||||||
})
|
// prefer deletion over everything else
|
||||||
})
|
// maybe we should warn when a file has both been replaced and marked for deletion
|
||||||
|
AttachmentEdit::Delete
|
||||||
|
} else {
|
||||||
|
// prefer the user specified file_name over the uploads file_name
|
||||||
|
let file_name = if specified_file_name.is_empty() {
|
||||||
|
// unless the user specified an empty string
|
||||||
|
upload_file_name
|
||||||
|
} else {
|
||||||
|
specified_file_name
|
||||||
|
};
|
||||||
|
AttachmentEdit::Edit {
|
||||||
|
title,
|
||||||
|
file_name,
|
||||||
|
file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(attachment_titles_new.len(), attachment_files_new.len());
|
||||||
|
|
||||||
|
let attachments_new = process_new_attachments(attachment_titles_new, attachment_files_new);
|
||||||
|
|
||||||
assert_eq!(link_titles.len(), link_urls.len());
|
assert_eq!(link_titles.len(), link_urls.len());
|
||||||
|
|
||||||
let links = process_links(link_titles, link_urls);
|
let links = process_links(link_titles, link_urls);
|
||||||
|
|
@ -355,7 +495,8 @@ impl JobOfferEditForm {
|
||||||
title: title.ok_or(FormProcessingError::MissingField {
|
title: title.ok_or(FormProcessingError::MissingField {
|
||||||
field: form_constants::TITLE,
|
field: form_constants::TITLE,
|
||||||
})?,
|
})?,
|
||||||
attachments,
|
attachment_edits,
|
||||||
|
attachments_new,
|
||||||
links,
|
links,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
src/util.rs
28
src/util.rs
|
|
@ -1,8 +1,9 @@
|
||||||
use crate::job_offers::Link;
|
use crate::job_offers::{Attachment, Link};
|
||||||
use better_toml_datetime::Offset;
|
use better_toml_datetime::Offset;
|
||||||
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
|
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
// we basically don't do any proper error handling here,
|
// we basically don't do any proper error handling here,
|
||||||
|
|
@ -200,3 +201,28 @@ pub(crate) fn process_links(titles: Vec<String>, urls: Vec<String>) -> Vec<Link>
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn process_new_attachments(
|
||||||
|
titles: Vec<String>,
|
||||||
|
files: Vec<(String, Option<NamedTempFile>)>,
|
||||||
|
) -> Vec<Attachment<NamedTempFile>> {
|
||||||
|
titles
|
||||||
|
.into_iter()
|
||||||
|
.zip(files.into_iter())
|
||||||
|
.filter_map(|(title, (file_name, attachment_location))| {
|
||||||
|
attachment_location.map(|attachment_location| (title, file_name, attachment_location))
|
||||||
|
})
|
||||||
|
.enumerate()
|
||||||
|
.map(
|
||||||
|
|(idx, (title, file_name, attachment_location))| Attachment {
|
||||||
|
title: if title.is_empty() {
|
||||||
|
format!("Attachment {}: {}", idx + 1, file_name)
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
},
|
||||||
|
file_name,
|
||||||
|
attachment_location,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ body main {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
.header-main {
|
||||||
flex-grow:1;
|
flex-grow:1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div class="header-main">
|
||||||
{{> header}}
|
{{> header}}
|
||||||
<main>
|
<main>
|
||||||
{{> @partial-block }}
|
{{> @partial-block }}
|
||||||
|
|
|
||||||
|
|
@ -18,36 +18,28 @@
|
||||||
|
|
||||||
<fieldset class="attachment-area">
|
<fieldset class="attachment-area">
|
||||||
<legend>Anhänge</legend>
|
<legend>Anhänge</legend>
|
||||||
{{#*inline "attachment" }}
|
{{#each form.attachments }}
|
||||||
|
{{#unless @first}}
|
||||||
|
<hr />
|
||||||
|
{{/unless}}
|
||||||
<div>
|
<div>
|
||||||
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
|
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
|
||||||
<input type="file" accept=".pdf" name="file[]"/>
|
<input type="file" accept=".pdf" name="file[]"/>
|
||||||
<div/>
|
<div/>
|
||||||
{{/inline}}
|
{{/each}}
|
||||||
{{> attachment }}
|
|
||||||
<hr />
|
|
||||||
{{> attachment }}
|
|
||||||
<hr />
|
|
||||||
{{> attachment }}
|
|
||||||
<hr />
|
|
||||||
{{> attachment }}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="link-area">
|
<fieldset class="link-area">
|
||||||
<legend>Links<sup>2</sup></legend>
|
<legend>Links<sup>2</sup></legend>
|
||||||
{{#*inline "link" }}
|
{{#each form.links }}
|
||||||
|
{{#unless @first}}
|
||||||
|
<hr />
|
||||||
|
{{/unless}}
|
||||||
<div>
|
<div>
|
||||||
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
|
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
|
||||||
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{base.routes.index}}" />
|
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{@root.base.routes.index}}" />
|
||||||
<div/>
|
<div/>
|
||||||
{{/inline}}
|
{{/each}}
|
||||||
{{> link }}
|
|
||||||
<hr />
|
|
||||||
{{> link }}
|
|
||||||
<hr />
|
|
||||||
{{> link }}
|
|
||||||
<hr />
|
|
||||||
{{> link }}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="notes">
|
<div class="notes">
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,24 @@
|
||||||
{{#unless @first}}
|
{{#unless @first}}
|
||||||
<hr />
|
<hr />
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
<input type="text" autocomplete="on" name="file_title[]" value="{{attachment.title}}" />
|
<input type="text" autocomplete="on" name="file_title_edit[]" value="{{attachment.title}}" />
|
||||||
<input type="text" name="file_name[]" value="{{attachment.file_name}}" />
|
<input type="text" name="file_name_edit[]" value="{{attachment.file_name}}" />
|
||||||
<a href="{{attachment.attachment_location}}" target="_blank">View Attachment</a>
|
| <a href="{{attachment.attachment_location}}" target="_blank">View Attachment</a>
|
||||||
|
| <label for="delete-{{@index}}">Delete Attachment</label> <input id="delete-{{@index}}" type="checkbox" name="delete_attachment[]" value="{{@index}}" />
|
||||||
|
| <label for="replace-{{@index}}">Replace Attachment</label> <input id="replace-{{@index}}" type="file" accept=".pdf" name="file_replace[]" title="Replace Attachment" />
|
||||||
|
<div/>
|
||||||
|
{{/each}}
|
||||||
|
{{#each form.remaining_attachments }}
|
||||||
|
{{#unless @first}}
|
||||||
|
<hr />
|
||||||
|
{{else}}
|
||||||
|
{{#if ../job_offer.attachments }}
|
||||||
|
<hr />
|
||||||
|
{{/if}}
|
||||||
|
{{/unless}}
|
||||||
|
<div>
|
||||||
|
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
|
||||||
|
<input type="file" accept=".pdf" name="file[]"/>
|
||||||
<div/>
|
<div/>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -39,8 +54,8 @@
|
||||||
<hr />
|
<hr />
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
<div>
|
<div>
|
||||||
<input type="text" autocomplete="on" name="link_title[]" value="{{link.title}}" />
|
<input type="text" autocomplete="on" name="link_title[]" {{#if link}}value="{{link.title}}"{{/if}} />
|
||||||
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" value="{{link.destination}}" />
|
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" {{#if link}}value="{{link.destination}}"{{/if}} />
|
||||||
<div/>
|
<div/>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue