features, cleanup and bug fixes #26
12 changed files with 214 additions and 82 deletions
show the user which entries will be deleted with delete expired
commit
8d0eacb79d
12
src/error.rs
12
src/error.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
53
src/route.rs
53
src/route.rs
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}">
|
||||
|
|
|
|||
19
templates/job_offer/delete-expired.hb
Normal file
19
templates/job_offer/delete-expired.hb
Normal 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}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue