features, cleanup and bug fixes #26

Merged
ben merged 30 commits from ben/Jobboerse:main into main 2022-06-09 17:36:55 +02:00
12 changed files with 214 additions and 82 deletions
Showing only changes of commit 8d0eacb79d - Show all commits

show the user which entries will be deleted with delete expired

Bennet Bleßmann 2022-05-30 18:03:23 +02:00 committed by Bennet Bleßmann
Signed by: ben
GPG key ID: 3BE1A1A3CBC3CF99

View file

@ -103,24 +103,24 @@ impl ResponseError for MultipartFieldError {
#[derive(Error, Debug)]
pub(crate) enum PresentationError {
#[error("Failed to render page template")]
#[error("Failed to render page template: {0}")]
Render(#[from] RenderError),
#[error("Failed to generate URL for route")]
#[error("Failed to generate URL for route: {0}")]
Url(#[from] UrlGenerationError),
#[error("Couldn't generate response as login is required")]
LoginRequired(#[from] LoginRequired),
}
impl ResponseError for PresentationError {
fn status_code(&self) -> StatusCode {
match self {
Self::Render(_) | Self::Url(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::LoginRequired(_) => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
PresentationError::Render(inner) => default_error_response(inner, status_code),
PresentationError::Url(inner) => default_error_response(inner, status_code),
}
default_error_response(self, status_code)
}
}

View file

@ -166,13 +166,12 @@ impl JobOfferActions {
}
}
pub(crate) fn job_data(
pub(crate) fn job_data<'i, I: Iterator<Item = (&'i str, &'i JobOffer<PathBuf>)>>(
req: &HttpRequest,
offers: BorrowedJobOffers<'_>,
offers: I,
user: Option<&User>,
) -> Vec<JobOfferData> {
let mut data: Vec<_> = offers
.iter()
.filter_map(|(id, offer)| match offer.to_real_data(id, req, user) {
Ok(offer) => offer,
Err(err) => {
@ -766,28 +765,6 @@ impl JobOffers {
Ok(())
}
pub(crate) async fn delete_expired(&self, config: &ServerConfig) -> Result<usize, DeleteError> {
let read_guard = self.data.read().await;
let expired_keys = self
.data
.read()
.await
.iter()
.filter_map(|(key, value)| value.is_expired().then(|| key.clone()))
.collect::<Vec<_>>();
// we need to drop the read guard before we try to acquire a write guard in delete_offer below otherwise we cause a dead-lock
drop(read_guard);
for key in &expired_keys {
// to not block readers for too long we perform individual deletes instead of a batch delete
self.delete_offer(key, true, config).await?
}
Ok(expired_keys.len())
}
}
pub(crate) struct BorrowedJobOffers<'a>(

View file

@ -1,4 +1,4 @@
use crate::error::default_error_response;
use crate::error::{default_error_response, LoginRequired};
use actix_web::body::BoxBody;
use actix_web::error::UrlGenerationError;
use actix_web::{HttpResponse, ResponseError};
@ -20,6 +20,8 @@ pub(crate) enum SaveError {
Email(#[from] EmailError),
#[error("The Runtime encountered an error!")]
Runtime(#[from] tokio::task::JoinError),
#[error("A reviewer-only setting was specified, but no valid session was found.")]
LoginRequired(#[from] LoginRequired),
}
impl ResponseError for SaveError {
@ -47,6 +49,7 @@ impl ResponseError for SaveError {
| SaveError::Runtime(_) => StatusCode::INTERNAL_SERVER_ERROR,
SaveError::AlreadyExists => StatusCode::CONFLICT,
SaveError::Email(_) => StatusCode::INTERNAL_SERVER_ERROR,
SaveError::LoginRequired(_) => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {

View file

@ -24,7 +24,7 @@ pub(crate) use job_offer::{
pub(crate) use license::{LICENSES_ROUTE, LICENSE_BUNDLE};
use crate::error::PresentationError;
use crate::route::job_offer::action::JOBOFFER_DELETE_EXPIRED_ROUTE;
use crate::route::job_offer::action::{JOBOFFER_BULK_DELETE_ROUTE, JOBOFFER_DELETE_EXPIRED_ROUTE};
use crate::server_config::OperationMode;
use crate::server_config::ServerConfig;
@ -86,6 +86,36 @@ struct StaticRoutes {
joboffer_summary: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
joboffers_delete_expired: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
joboffers_bulk_delete: Url,
}
impl StaticRoutes {
fn new(req: &HttpRequest) -> Result<Self, UrlGenerationError> {
let licenses_route = req.url_for_static(LICENSES_ROUTE)?;
let login_route = req.url_for_static(LOGIN_ROUTE)?;
let logout_route = req.url_for_static(LOGOUT_ROUTE)?;
let sync_route = req.url_for_static(JOBOFFER_SYNC_ROUTE)?;
let index_route = req.url_for_static(INDEX_ROUTE)?;
let joboffer_overview_route = req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?;
let joboffer_creation_route = req.url_for_static(JOBOFFER_CREATION_ROUTE)?;
let joboffer_summary_route = req.url_for_static(JOBOFFER_SUMMARY_ROUTE)?;
let joboffers_delete_expired_route = req.url_for_static(JOBOFFER_DELETE_EXPIRED_ROUTE)?;
let joboffers_bulk_delete_route = req.url_for_static(JOBOFFER_BULK_DELETE_ROUTE)?;
Ok(StaticRoutes {
licenses: licenses_route,
login: login_route,
logout: logout_route,
sync: sync_route,
index: index_route,
joboffer_overview: joboffer_overview_route,
joboffer_create: joboffer_creation_route,
joboffer_summary: joboffer_summary_route,
joboffers_delete_expired: joboffers_delete_expired_route,
joboffers_bulk_delete: joboffers_bulk_delete_route,
})
}
}
fn url_as_string<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
@ -116,15 +146,6 @@ fn base<'a>(
config: &'a ServerConfig,
title: &'a str,
) -> Result<BaseData<'a>, UrlGenerationError> {
let licenses_route = req.url_for_static(LICENSES_ROUTE)?;
let login_route = req.url_for_static(LOGIN_ROUTE)?;
let logout_route = req.url_for_static(LOGOUT_ROUTE)?;
let sync_route = req.url_for_static(JOBOFFER_SYNC_ROUTE)?;
let index_route = req.url_for_static(INDEX_ROUTE)?;
let joboffer_overview_route = req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?;
let joboffer_creation_route = req.url_for_static(JOBOFFER_CREATION_ROUTE)?;
let joboffer_summary_route = req.url_for_static(JOBOFFER_SUMMARY_ROUTE)?;
let joboffers_delete_expired_route = req.url_for_static(JOBOFFER_DELETE_EXPIRED_ROUTE)?;
let index_css = req.url_for_static(INDEX_CSS_ROUTE)?;
let mut default_links = vec![(
@ -165,17 +186,7 @@ fn base<'a>(
short_lang: "de".into(),
styles: vec![index_css],
links,
routes: StaticRoutes {
licenses: licenses_route,
login: login_route,
logout: logout_route,
sync: sync_route,
index: index_route,
joboffer_overview: joboffer_overview_route,
joboffer_create: joboffer_creation_route,
joboffer_summary: joboffer_summary_route,
joboffers_delete_expired: joboffers_delete_expired_route,
},
routes: StaticRoutes::new(req)?,
banner: config.config.banner.clone(),
operation_mode: config.args.mode.clone(),
dev_build: dev_available,

View file

@ -3,7 +3,7 @@ use actix_web::web::ServiceConfig;
use actix_web::{get, http, post, web, HttpRequest, HttpResponse, Responder};
use handlebars::Handlebars;
use log::{error, trace};
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::error::{AuthenticationError, PresentationError};
@ -28,17 +28,10 @@ pub(crate) const LOGIN_ROUTE: &str = "login";
#[derive(Serialize, Deserialize)]
pub(crate) struct LoginQuery {
return_to: Option<String>,
#[serde(deserialize_with = "boolean_from_flag", default)]
#[serde(deserialize_with = "crate::util::boolean_from_flag", default)]
retry: bool,
}
fn boolean_from_flag<'de, D>(des: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
<Option<String> as Deserialize>::deserialize(des).map(|option| option.is_some())
}
#[get("/login", name = "login")]
pub(crate) async fn login_get(
req: HttpRequest,

View file

@ -4,7 +4,7 @@ use crate::job_offers::error::{DeleteError, SaveError};
use crate::route::LOGIN_ROUTE;
use crate::{route, ServerConfig};
use crate::route::job_offer::error::{ConfirmationError, SubmissionError};
use crate::route::job_offer::error::{ConfirmationError, DeletionError, SubmissionError};
use actix_session::SessionExt;
use actix_web::dev::ServiceResponse;
use actix_web::error::UrlGenerationError;
@ -184,8 +184,22 @@ pub(crate) fn bad_request<B>(
warn!("Unexpected MultipartFieldError as top level error!");
msg = err.to_string();
Some(msg.as_str())
} else if let Some(err) = err.as_error::<DeletionError>() {
match err {
DeletionError::Delete(err) => {
warn!("Couldn't delete Job Offer: {}", err);
Some("Could not delete a Job Offer")
}
DeletionError::Login(_) => {
error!(
"Response Status Code (Bad Request) and Error appear to disagree : {}",
err
);
None
}
}
} else {
warn!("Bad Request Error of unknown type!");
warn!("Bad Request Error of unknown type!: {}", err);
None
}
} else {
@ -211,7 +225,28 @@ pub(crate) fn unauthorized_error_handler<B>(
if let Some(return_url) = res
.response()
.error()
.and_then(|err| err.as_error::<LoginRequired>())
.and_then(|err| {
err.as_error::<LoginRequired>()
.or_else(|| {
err.as_error::<SaveError>().and_then(|elem| match elem {
SaveError::LoginRequired(login) => Some(login),
_ => None,
})
})
.or_else(|| {
err.as_error::<DeletionError>().and_then(|elem| match elem {
DeletionError::Login(login) => Some(login),
_ => None,
})
})
.or_else(|| {
err.as_error::<PresentationError>()
.and_then(|elem| match elem {
PresentationError::LoginRequired(login) => Some(login),
_ => None,
})
})
})
.and_then(|err| err.get_return())
{
// we have a LoginRequired error type with a return_to URL set, redirect the user to the login page with the

View file

@ -32,6 +32,7 @@ pub fn configure(service: &mut ServiceConfig) {
.service(confirmation::confirm_joboffer_post)
.service(confirmation::reject_joboffer_post)
.service(action::delete_joboffer)
.service(action::bulk_delete)
.service(action::delete_expired_joboffers)
.service(action::review_joboffer)
.service(action::unpublish_joboffer)
@ -54,7 +55,7 @@ pub(crate) async fn index(
let job_offers = {
let guard = offers.get_offers().await;
crate::job_offers::job_data(&req, guard, user.as_ref())
crate::job_offers::job_data(&req, guard.iter(), user.as_ref())
};
let base_data = super::base(&req, &config, "Joboffers")?;
@ -157,7 +158,7 @@ pub(crate) async fn summary(
let user = User::current(&session).ok();
let previews = {
let guard = offers.get_offers().await;
crate::job_offers::job_data(&req, guard, user.as_ref())
crate::job_offers::job_data(&req, guard.iter(), user.as_ref())
};
let data = json!(previews);
Ok(HttpResponse::Ok()

View file

@ -1,9 +1,15 @@
use actix_session::Session;
use actix_web::{http, post, web, HttpRequest, HttpResponse};
use actix_web::{get, http, post, web, HttpRequest, HttpResponse};
use handlebars::Handlebars;
use log::debug;
use serde::{Deserialize, Deserializer};
use serde_json::json;
use crate::auth::User;
use crate::error::PresentationError;
use crate::route::job_offer::error::{DeletionError, StateChangeError};
use crate::route::JOBOFFER_OVERVIEW_ROUTE;
use crate::{auth, JobOffers, ServerConfig};
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
use crate::{auth, template, JobOffers, ServerConfig};
pub(crate) const JOBOFFER_DELETION_ROUTE: &str = "joboffer_delete";
@ -34,18 +40,101 @@ pub(crate) async fn delete_joboffer(
pub(crate) const JOBOFFER_DELETE_EXPIRED_ROUTE: &str = "joboffers_delete_expired";
#[post("/delete_expired", name = "joboffers_delete_expired")]
#[get("/delete_expired", name = "joboffers_delete_expired")]
pub(crate) async fn delete_expired_joboffers(
req: HttpRequest,
hb: web::Data<Handlebars<'_>>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionError> {
// TODO return the user to a page where they are asked to confirm deletion
// aka. the get variant of this route
let _user = auth::User::current(&session)?;
) -> actix_web::Result<HttpResponse, PresentationError> {
let user = User::current(&session)?;
let offers_guard = offers.get_offers().await;
offers.delete_expired(&config).await?;
let job_offers = crate::job_offers::job_data(
&req,
offers_guard.iter().filter(|(_, offer)| offer.is_expired()),
Some(&user),
);
let base = crate::route::base(&req, &config, "Delete Expired")?;
let data = json! {{
"base": base,
"expired_job_offers": job_offers,
}};
let rendered = hb.render(template::JOBOFFER_DELETE_EXPIRED, &data)?;
Ok(HttpResponse::Ok()
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.body(rendered))
}
pub(crate) struct BulkDeleteData {
only_expired: bool,
ids: Vec<String>,
}
impl<'de> Deserialize<'de> for BulkDeleteData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let key_value_pairs = <Vec<(String, String)>>::deserialize(deserializer)?;
let mut only_expired = None;
let mut ids = Vec::new();
for (key, value) in key_value_pairs {
match key.as_str() {
"id[]" => ids.push(value),
"only_expired" => {
if only_expired.is_some() {
return Err(D::Error::duplicate_field("only_expired"));
}
match value.as_str() {
"true" => only_expired = Some(true),
"false" => only_expired = Some(false),
value => {
return Err(D::Error::invalid_value(
serde::de::Unexpected::Other(value),
&"either 'true' or 'false'",
));
}
}
}
field => return Err(D::Error::unknown_field(field, &["id[]", "only_expired"])),
}
}
if let Some(only_expired) = only_expired {
Ok(BulkDeleteData { only_expired, ids })
} else {
Err(D::Error::missing_field("only_expired"))
}
}
}
pub(crate) const JOBOFFER_BULK_DELETE_ROUTE: &str = "joboffers_bulk_delete";
#[post("/bulk_delete", name = "joboffers_bulk_delete")]
pub(crate) async fn bulk_delete(
req: HttpRequest,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
form: web::Form<BulkDeleteData>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionError> {
debug!("Received bulk deletion request!");
let _user = User::current(&session)?;
for id in &form.ids {
offers.delete_offer(id, form.only_expired, &config).await?;
}
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)

View file

@ -5,6 +5,7 @@ pub(crate) const JOBOFFER_ATTACHMENT_PREVIEW: &str = "job_offer/attachement-prev
pub(crate) const JOBOFFER_CONFIRM_SUBMISSION: &str = "job_offer/submission-confirm";
pub(crate) const JOBOFFER_CONFIRM_SUBMISSION_SUCCESS: &str = "job_offer/submission-confirm-success";
pub(crate) const JOBOFFER_REJECT_SUBMISSION_SUCCESS: &str = "job_offer/submission-rejected-success";
pub(crate) const JOBOFFER_DELETE_EXPIRED: &str = "job_offer/delete-expired";
pub(crate) const AUTH_LOGIN: &str = "auth/login";
pub(crate) const LICENCES: &str = "licenses";

View file

@ -1,4 +1,5 @@
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
use serde::{Deserialize, Deserializer};
use toml::value::Offset;
// we basically don't do any proper error handling here,
@ -123,3 +124,10 @@ pub fn toml_datetime_to_chrono_datetime(
offset.from_local_datetime(&local_datetime).unwrap()
}
pub(crate) fn boolean_from_flag<'de, D>(des: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
<Option<String> as Deserialize>::deserialize(des).map(|option| option.is_some())
}

View file

@ -6,12 +6,7 @@
<span>|</span><a class="footer-element" href="{{base.routes.licenses}}"><h3 class="inline">Third-party Licenses</h3></a>
<span>|</span><a class="footer-element" href="{{base.routes.joboffer_create}}"><h3 class="inline">Stellenanzeige Einreichen</h3></a>
{{#if user}}
<span>|</span><label class="footer-element" for="submit-delete-expired" tabindex="0">
<h3 class="inline" title="Delete all expired Job Offers">Delete Expired</h3>
<form class="hidden" method="post" action="{{base.routes.joboffers_delete_expired}}">
<input type="submit" id="submit-delete-expired" class="hidden">
</form>
</label>
<span>|</span><a class="footer-element" href="{{base.routes.joboffers_delete_expired}}" title="Delete all expired Job Offers"><h3 class="inline">Delete Expired</h3></a>
<span>|</span><label class="footer-element" for="submit-sync" tabindex="0">
<h3 class="inline" title="Reload Joboffer Metadata from Disk">Re-Sync</h3>
<form class="hidden" method="post" action="{{base.routes.sync}}">

View file

@ -0,0 +1,19 @@
{{#> base}}
<form method="post" action="{{base.routes.joboffers_bulk_delete}}">
<div class="joboffer-index">
{{#each expired_job_offers as |job_offer|}}
<label for="check-{{job_offer.id}}">
<input id="check-{{job_offer.id}}" type="checkbox" name="id[]" value="{{job_offer.id}}" checked="checked">
{{> job_offer/overview-entry job_offer=job_offer base=../base user=../user}}
</label>
{{else}}
There are no expired job offers to delete!
{{/each}}
</div>
{{#if expired_job_offers }}
<input type="hidden" name="only_expired" value="true">
<hr />
<button type="submit">Delete selected Job Offers</button>
{{/if}}
</form>
{{/base}}