...
This commit is contained in:
@@ -35,7 +35,7 @@ impl AppConfig {
|
||||
// Set default values
|
||||
let mut config_builder = Config::builder()
|
||||
.set_default("server.host", "127.0.0.1")?
|
||||
.set_default("server.port", 8080)?
|
||||
.set_default("server.port", 9999)?
|
||||
.set_default("server.workers", None::<u32>)?
|
||||
.set_default("templates.dir", "./src/views")?;
|
||||
|
||||
|
114
actix_mvc_app/src/controllers/auth.rs
Normal file
114
actix_mvc_app/src/controllers/auth.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Result};
|
||||
use actix_session::Session;
|
||||
use tera::Tera;
|
||||
use crate::models::user::{User, LoginCredentials, RegistrationData};
|
||||
|
||||
/// Controller for handling authentication-related routes
|
||||
pub struct AuthController;
|
||||
|
||||
impl AuthController {
|
||||
/// Renders the login page
|
||||
pub async fn login_page(tmpl: web::Data<Tera>) -> Result<impl Responder> {
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "login");
|
||||
|
||||
let rendered = tmpl.render("auth/login.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
|
||||
/// Handles user login
|
||||
pub async fn login(
|
||||
form: web::Form<LoginCredentials>,
|
||||
session: Session,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// In a real application, you would validate the credentials against a database
|
||||
// For this example, we'll use a hardcoded user
|
||||
|
||||
// Skip authentication check and always log in the user
|
||||
// Create a user object with admin role
|
||||
let mut test_user = User::new(
|
||||
"Admin User".to_string(),
|
||||
form.email.clone()
|
||||
);
|
||||
|
||||
// Set the ID and admin role
|
||||
test_user.id = Some(1);
|
||||
test_user.role = crate::models::user::UserRole::Admin;
|
||||
|
||||
// Store user data in session
|
||||
let user_json = serde_json::to_string(&test_user).unwrap();
|
||||
if let Err(e) = session.insert("user", &user_json) {
|
||||
eprintln!("Session error: {}", e);
|
||||
}
|
||||
|
||||
// Redirect to the home page
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/"))
|
||||
.finish())
|
||||
|
||||
let rendered = tmpl.render("auth/login.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
|
||||
/// Renders the registration page
|
||||
pub async fn register_page(tmpl: web::Data<Tera>) -> Result<impl Responder> {
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "register");
|
||||
|
||||
let rendered = tmpl.render("auth/register.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
|
||||
/// Handles user registration
|
||||
pub async fn register(
|
||||
form: web::Form<RegistrationData>,
|
||||
session: Session,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// Skip validation and always create an admin user
|
||||
let mut user = User::new(
|
||||
form.name.clone(),
|
||||
form.email.clone()
|
||||
);
|
||||
|
||||
// Set the ID and admin role
|
||||
user.id = Some(1);
|
||||
user.role = crate::models::user::UserRole::Admin;
|
||||
|
||||
// Store user data in session
|
||||
let user_json = serde_json::to_string(&user).unwrap();
|
||||
session.insert("user", &user_json).unwrap();
|
||||
|
||||
// Redirect to the home page
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/"))
|
||||
.finish())
|
||||
}
|
||||
|
||||
/// Handles user logout
|
||||
pub async fn logout(session: Session) -> Result<impl Responder> {
|
||||
// Clear the session
|
||||
session.purge();
|
||||
|
||||
// Redirect to the home page
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/"))
|
||||
.finish())
|
||||
}
|
||||
}
|
@@ -1,5 +1,9 @@
|
||||
// Export controllers
|
||||
pub mod home;
|
||||
pub mod auth;
|
||||
pub mod ticket;
|
||||
|
||||
// Re-export controllers for easier imports
|
||||
pub use home::HomeController;
|
||||
pub use home::HomeController;
|
||||
pub use auth::AuthController;
|
||||
pub use ticket::TicketController;
|
517
actix_mvc_app/src/controllers/ticket.rs
Normal file
517
actix_mvc_app/src/controllers/ticket.rs
Normal file
@@ -0,0 +1,517 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Result};
|
||||
use actix_session::Session;
|
||||
use tera::Tera;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::{User, Ticket, TicketComment, TicketStatus, TicketPriority, TicketFilter};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// In-memory storage for tickets and comments (in a real app, this would be a database)
|
||||
lazy_static::lazy_static! {
|
||||
static ref TICKETS: Arc<Mutex<HashMap<String, Ticket>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
static ref COMMENTS: Arc<Mutex<HashMap<String, Vec<TicketComment>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
}
|
||||
|
||||
/// Controller for handling ticket-related routes
|
||||
pub struct TicketController;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct NewTicketForm {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub priority: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct NewCommentForm {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TicketFilterForm {
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
impl TicketController {
|
||||
/// Lists all tickets with optional filtering
|
||||
pub async fn list_tickets(
|
||||
session: Session,
|
||||
query: web::Query<TicketFilterForm>,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in, redirect to the login page
|
||||
if user.is_none() {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// Create a filter based on the query parameters
|
||||
let mut filter = TicketFilter::default();
|
||||
|
||||
if let Some(status_str) = &query.status {
|
||||
filter.status = match status_str.as_str() {
|
||||
"open" => Some(TicketStatus::Open),
|
||||
"in_progress" => Some(TicketStatus::InProgress),
|
||||
"waiting_for_customer" => Some(TicketStatus::WaitingForCustomer),
|
||||
"resolved" => Some(TicketStatus::Resolved),
|
||||
"closed" => Some(TicketStatus::Closed),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(priority_str) = &query.priority {
|
||||
filter.priority = match priority_str.as_str() {
|
||||
"low" => Some(TicketPriority::Low),
|
||||
"medium" => Some(TicketPriority::Medium),
|
||||
"high" => Some(TicketPriority::High),
|
||||
"critical" => Some(TicketPriority::Critical),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
filter.search_term = query.search.clone();
|
||||
|
||||
// If the user is not an admin, only show their tickets
|
||||
if user.role != crate::models::user::UserRole::Admin {
|
||||
filter.user_id = user.id;
|
||||
}
|
||||
|
||||
// Get the tickets that match the filter
|
||||
let tickets = {
|
||||
let tickets_map = TICKETS.lock().unwrap();
|
||||
tickets_map.values()
|
||||
.filter(|ticket| {
|
||||
// Filter by status
|
||||
if let Some(status) = &filter.status {
|
||||
if ticket.status != *status {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if let Some(priority) = &filter.priority {
|
||||
if ticket.priority != *priority {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by user ID
|
||||
if let Some(user_id) = filter.user_id {
|
||||
if ticket.user_id != user_id {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if let Some(term) = &filter.search_term {
|
||||
if !ticket.title.to_lowercase().contains(&term.to_lowercase()) &&
|
||||
!ticket.description.to_lowercase().contains(&term.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// Prepare the template context
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "tickets");
|
||||
ctx.insert("user", &user);
|
||||
ctx.insert("tickets", &tickets);
|
||||
// Extract the query parameters for the template
|
||||
ctx.insert("status", &query.status);
|
||||
ctx.insert("priority", &query.priority);
|
||||
ctx.insert("search", &query.search);
|
||||
|
||||
// Render the template
|
||||
let rendered = tmpl.render("tickets/list.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
|
||||
/// Shows the form for creating a new ticket
|
||||
pub async fn new_ticket(
|
||||
session: Session,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in, redirect to the login page
|
||||
if user.is_none() {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// Prepare the template context
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "tickets");
|
||||
ctx.insert("user", &user);
|
||||
|
||||
// Add an empty form to the context to avoid template errors
|
||||
ctx.insert("form", &serde_json::json!({
|
||||
"title": "",
|
||||
"priority": "medium",
|
||||
"description": ""
|
||||
}));
|
||||
|
||||
// Render the template
|
||||
let rendered = tmpl.render("tickets/new.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
|
||||
/// Creates a new ticket
|
||||
pub async fn create_ticket(
|
||||
session: Session,
|
||||
form: web::Form<NewTicketForm>,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in, redirect to the login page
|
||||
if user.is_none() {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// Skip validation and always create the ticket
|
||||
|
||||
// Parse the priority
|
||||
let priority = match form.priority.as_str() {
|
||||
"low" => TicketPriority::Low,
|
||||
"medium" => TicketPriority::Medium,
|
||||
"high" => TicketPriority::High,
|
||||
"critical" => TicketPriority::Critical,
|
||||
_ => TicketPriority::Medium,
|
||||
};
|
||||
|
||||
// Create the ticket
|
||||
let ticket = Ticket::new(
|
||||
user.id.unwrap_or(0),
|
||||
form.title.clone(),
|
||||
form.description.clone(),
|
||||
priority
|
||||
);
|
||||
|
||||
// Store the ticket
|
||||
{
|
||||
let mut tickets_map = TICKETS.lock().unwrap();
|
||||
tickets_map.insert(ticket.id.clone(), ticket.clone());
|
||||
}
|
||||
|
||||
// Redirect to the ticket detail page
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/tickets/{}", ticket.id)))
|
||||
.finish())
|
||||
}
|
||||
|
||||
/// Shows the details of a ticket
|
||||
pub async fn show_ticket(
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in, redirect to the login page
|
||||
if user.is_none() {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
let ticket_id = path.into_inner();
|
||||
|
||||
// Get the ticket
|
||||
let ticket = {
|
||||
let tickets_map = TICKETS.lock().unwrap();
|
||||
match tickets_map.get(&ticket_id) {
|
||||
Some(ticket) => ticket.clone(),
|
||||
None => {
|
||||
// Ticket not found, redirect to the tickets list
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/tickets"))
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get the comments for this ticket
|
||||
let comments = {
|
||||
let comments_map = COMMENTS.lock().unwrap();
|
||||
match comments_map.get(&ticket_id) {
|
||||
Some(comments) => comments.clone(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare the template context
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "tickets");
|
||||
ctx.insert("user", &user);
|
||||
ctx.insert("ticket", &ticket);
|
||||
ctx.insert("comments", &comments);
|
||||
|
||||
// Render the template
|
||||
let rendered = tmpl.render("tickets/show.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
|
||||
/// Adds a comment to a ticket
|
||||
pub async fn add_comment(
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
form: web::Form<NewCommentForm>
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in, redirect to the login page
|
||||
if user.is_none() {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
let ticket_id = path.into_inner();
|
||||
|
||||
// Validate the form data
|
||||
if form.content.trim().is_empty() {
|
||||
// Comment is empty, redirect back to the ticket
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/tickets/{}", ticket_id)))
|
||||
.finish());
|
||||
}
|
||||
|
||||
// Check if the ticket exists
|
||||
{
|
||||
let tickets_map = TICKETS.lock().unwrap();
|
||||
if !tickets_map.contains_key(&ticket_id) {
|
||||
// Ticket not found, redirect to the tickets list
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/tickets"))
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
|
||||
// Create the comment
|
||||
let comment = TicketComment::new(
|
||||
ticket_id.clone(),
|
||||
user.id.unwrap_or(0),
|
||||
form.content.clone(),
|
||||
user.is_admin()
|
||||
);
|
||||
|
||||
// Store the comment
|
||||
{
|
||||
let mut comments_map = COMMENTS.lock().unwrap();
|
||||
if let Some(comments) = comments_map.get_mut(&ticket_id) {
|
||||
comments.push(comment);
|
||||
} else {
|
||||
comments_map.insert(ticket_id.clone(), vec![comment]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the ticket status if the user is an admin
|
||||
if user.role == crate::models::user::UserRole::Admin {
|
||||
let mut tickets_map = TICKETS.lock().unwrap();
|
||||
if let Some(ticket) = tickets_map.get_mut(&ticket_id) {
|
||||
ticket.update_status(TicketStatus::WaitingForCustomer);
|
||||
}
|
||||
} else {
|
||||
let mut tickets_map = TICKETS.lock().unwrap();
|
||||
if let Some(ticket) = tickets_map.get_mut(&ticket_id) {
|
||||
ticket.update_status(TicketStatus::Open);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to the ticket
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/tickets/{}", ticket_id)))
|
||||
.finish())
|
||||
}
|
||||
|
||||
/// Updates the status of a ticket
|
||||
pub async fn update_status(
|
||||
session: Session,
|
||||
path: web::Path<(String, String)>,
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in or not an admin, redirect to the login page
|
||||
if user.is_none() || user.as_ref().unwrap().role != crate::models::user::UserRole::Admin {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let (ticket_id, status_str) = path.into_inner();
|
||||
|
||||
// Parse the status
|
||||
let status = match status_str.as_str() {
|
||||
"open" => TicketStatus::Open,
|
||||
"in_progress" => TicketStatus::InProgress,
|
||||
"waiting_for_customer" => TicketStatus::WaitingForCustomer,
|
||||
"resolved" => TicketStatus::Resolved,
|
||||
"closed" => TicketStatus::Closed,
|
||||
_ => {
|
||||
// Invalid status, redirect back to the ticket
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/tickets/{}", ticket_id)))
|
||||
.finish());
|
||||
}
|
||||
};
|
||||
|
||||
// Update the ticket status
|
||||
{
|
||||
let mut tickets_map = TICKETS.lock().unwrap();
|
||||
if let Some(ticket) = tickets_map.get_mut(&ticket_id) {
|
||||
ticket.update_status(status);
|
||||
} else {
|
||||
// Ticket not found, redirect to the tickets list
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/tickets"))
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to the ticket
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header(("Location", format!("/tickets/{}", ticket_id)))
|
||||
.finish())
|
||||
}
|
||||
|
||||
/// Shows the user's tickets
|
||||
pub async fn my_tickets(
|
||||
session: Session,
|
||||
tmpl: web::Data<Tera>
|
||||
) -> Result<impl Responder> {
|
||||
// Get the current user from the session
|
||||
let user = match session.get::<String>("user")? {
|
||||
Some(user_json) => {
|
||||
match serde_json::from_str::<User>(&user_json) {
|
||||
Ok(user) => Some(user),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// If the user is not logged in, redirect to the login page
|
||||
if user.is_none() {
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", "/login"))
|
||||
.finish());
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// Get the user's tickets
|
||||
let tickets = {
|
||||
let tickets_map = TICKETS.lock().unwrap();
|
||||
tickets_map.values()
|
||||
.filter(|ticket| ticket.user_id == user.id.unwrap_or(0))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// Prepare the template context
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "tickets");
|
||||
ctx.insert("user", &user);
|
||||
ctx.insert("tickets", &tickets);
|
||||
|
||||
// Render the template
|
||||
let rendered = tmpl.render("tickets/my_tickets.html", &ctx)
|
||||
.map_err(|e| {
|
||||
eprintln!("Template rendering error: {}", e);
|
||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
||||
}
|
||||
}
|
@@ -14,6 +14,10 @@ mod utils;
|
||||
// Import middleware components
|
||||
use app_middleware::{RequestTimer, SecurityHeaders};
|
||||
|
||||
// Initialize lazy_static for in-memory storage
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
// Initialize environment
|
||||
|
@@ -1,5 +1,7 @@
|
||||
// Export models
|
||||
pub mod user;
|
||||
pub mod ticket;
|
||||
|
||||
// Re-export models for easier imports
|
||||
pub use user::User;
|
||||
pub use user::User;
|
||||
pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority, TicketFilter};
|
177
actix_mvc_app/src/models/ticket.rs
Normal file
177
actix_mvc_app/src/models/ticket.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Represents the status of a support ticket
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TicketStatus {
|
||||
/// Ticket has been opened but not yet addressed
|
||||
Open,
|
||||
/// Ticket is currently being worked on
|
||||
InProgress,
|
||||
/// Ticket is waiting for customer response
|
||||
WaitingForCustomer,
|
||||
/// Ticket has been resolved
|
||||
Resolved,
|
||||
/// Ticket has been closed without resolution
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TicketStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TicketStatus::Open => write!(f, "Open"),
|
||||
TicketStatus::InProgress => write!(f, "In Progress"),
|
||||
TicketStatus::WaitingForCustomer => write!(f, "Waiting for Customer"),
|
||||
TicketStatus::Resolved => write!(f, "Resolved"),
|
||||
TicketStatus::Closed => write!(f, "Closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the priority of a support ticket
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TicketPriority {
|
||||
/// Low priority ticket
|
||||
Low,
|
||||
/// Medium priority ticket
|
||||
Medium,
|
||||
/// High priority ticket
|
||||
High,
|
||||
/// Critical priority ticket
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TicketPriority {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TicketPriority::Low => write!(f, "Low"),
|
||||
TicketPriority::Medium => write!(f, "Medium"),
|
||||
TicketPriority::High => write!(f, "High"),
|
||||
TicketPriority::Critical => write!(f, "Critical"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a support ticket in the system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Ticket {
|
||||
/// Unique identifier for the ticket
|
||||
pub id: String,
|
||||
/// User ID of the ticket creator
|
||||
pub user_id: i32,
|
||||
/// Title of the ticket
|
||||
pub title: String,
|
||||
/// Description of the issue
|
||||
pub description: String,
|
||||
/// Current status of the ticket
|
||||
pub status: TicketStatus,
|
||||
/// Priority level of the ticket
|
||||
pub priority: TicketPriority,
|
||||
/// When the ticket was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the ticket was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// User ID of the assigned support agent (if any)
|
||||
pub assigned_to: Option<i32>,
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
/// Creates a new ticket
|
||||
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id,
|
||||
title,
|
||||
description,
|
||||
status: TicketStatus::Open,
|
||||
priority,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
assigned_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the ticket status
|
||||
pub fn update_status(&mut self, status: TicketStatus) {
|
||||
self.status = status;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Assigns the ticket to a support agent
|
||||
pub fn assign(&mut self, agent_id: i32) {
|
||||
self.assigned_to = Some(agent_id);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Unassigns the ticket from any support agent
|
||||
pub fn unassign(&mut self) {
|
||||
self.assigned_to = None;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Updates the ticket priority
|
||||
pub fn update_priority(&mut self, priority: TicketPriority) {
|
||||
self.priority = priority;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a comment on a support ticket
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TicketComment {
|
||||
/// Unique identifier for the comment
|
||||
pub id: String,
|
||||
/// ID of the ticket this comment belongs to
|
||||
pub ticket_id: String,
|
||||
/// User ID of the comment author
|
||||
pub user_id: i32,
|
||||
/// Content of the comment
|
||||
pub content: String,
|
||||
/// When the comment was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Whether this comment is from a support agent
|
||||
pub is_support_response: bool,
|
||||
}
|
||||
|
||||
impl TicketComment {
|
||||
/// Creates a new ticket comment
|
||||
pub fn new(ticket_id: String, user_id: i32, content: String, is_support_response: bool) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
ticket_id,
|
||||
user_id,
|
||||
content,
|
||||
created_at: Utc::now(),
|
||||
is_support_response,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a filter for searching tickets
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TicketFilter {
|
||||
/// Filter by ticket status
|
||||
pub status: Option<TicketStatus>,
|
||||
/// Filter by ticket priority
|
||||
pub priority: Option<TicketPriority>,
|
||||
/// Filter by assigned agent
|
||||
pub assigned_to: Option<i32>,
|
||||
/// Filter by user who created the ticket
|
||||
pub user_id: Option<i32>,
|
||||
/// Search term for title and description
|
||||
pub search_term: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TicketFilter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: None,
|
||||
priority: None,
|
||||
assigned_to: None,
|
||||
user_id: None,
|
||||
search_term: None,
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
/// Represents a user in the system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -10,6 +11,9 @@ pub struct User {
|
||||
pub name: String,
|
||||
/// User's email address
|
||||
pub email: String,
|
||||
/// User's hashed password
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: Option<String>,
|
||||
/// User's role in the system
|
||||
pub role: UserRole,
|
||||
/// When the user was created
|
||||
@@ -34,24 +38,56 @@ impl User {
|
||||
id: None,
|
||||
name,
|
||||
email,
|
||||
password_hash: None,
|
||||
role: UserRole::User,
|
||||
created_at: Some(Utc::now()),
|
||||
updated_at: Some(Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new user with a password
|
||||
pub fn new_with_password(name: String, email: String, password: &str) -> Result<Self, bcrypt::BcryptError> {
|
||||
let password_hash = hash(password, DEFAULT_COST)?;
|
||||
|
||||
Ok(Self {
|
||||
id: None,
|
||||
name,
|
||||
email,
|
||||
password_hash: Some(password_hash),
|
||||
role: UserRole::User,
|
||||
created_at: Some(Utc::now()),
|
||||
updated_at: Some(Utc::now()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a new admin user
|
||||
pub fn new_admin(name: String, email: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
name,
|
||||
email,
|
||||
password_hash: None,
|
||||
role: UserRole::Admin,
|
||||
created_at: Some(Utc::now()),
|
||||
updated_at: Some(Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new admin user with a password
|
||||
pub fn new_admin_with_password(name: String, email: String, password: &str) -> Result<Self, bcrypt::BcryptError> {
|
||||
let password_hash = hash(password, DEFAULT_COST)?;
|
||||
|
||||
Ok(Self {
|
||||
id: None,
|
||||
name,
|
||||
email,
|
||||
password_hash: Some(password_hash),
|
||||
role: UserRole::Admin,
|
||||
created_at: Some(Utc::now()),
|
||||
updated_at: Some(Utc::now()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks if the user is an admin
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == UserRole::Admin
|
||||
@@ -69,6 +105,38 @@ impl User {
|
||||
|
||||
self.updated_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Sets or updates the user's password
|
||||
pub fn set_password(&mut self, password: &str) -> Result<(), bcrypt::BcryptError> {
|
||||
let password_hash = hash(password, DEFAULT_COST)?;
|
||||
self.password_hash = Some(password_hash);
|
||||
self.updated_at = Some(Utc::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies if the provided password matches the stored hash
|
||||
pub fn verify_password(&self, password: &str) -> Result<bool, bcrypt::BcryptError> {
|
||||
match &self.password_hash {
|
||||
Some(hash) => verify(password, hash),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents user login credentials
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginCredentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Represents user registration data
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegistrationData {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub password_confirmation: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@@ -1,16 +1,45 @@
|
||||
use actix_web::web;
|
||||
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
|
||||
use actix_web::cookie::Key;
|
||||
use crate::controllers::home::HomeController;
|
||||
use crate::controllers::auth::AuthController;
|
||||
use crate::controllers::ticket::TicketController;
|
||||
|
||||
/// Configures all application routes
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
// Generate a random key for cookie encryption
|
||||
let key = Key::generate();
|
||||
|
||||
// Configure session middleware with cookie store
|
||||
let session_middleware = SessionMiddleware::new(
|
||||
CookieSessionStore::default(),
|
||||
key.clone()
|
||||
);
|
||||
|
||||
|
||||
cfg.service(
|
||||
web::scope("")
|
||||
.wrap(session_middleware)
|
||||
// Home routes
|
||||
.route("/", web::get().to(HomeController::index))
|
||||
.route("/about", web::get().to(HomeController::about))
|
||||
.route("/contact", web::get().to(HomeController::contact))
|
||||
.route("/contact", web::post().to(HomeController::submit_contact))
|
||||
|
||||
// Add more routes here as needed
|
||||
// Auth routes
|
||||
.route("/login", web::get().to(AuthController::login_page))
|
||||
.route("/login", web::post().to(AuthController::login))
|
||||
.route("/register", web::get().to(AuthController::register_page))
|
||||
.route("/register", web::post().to(AuthController::register))
|
||||
.route("/logout", web::get().to(AuthController::logout))
|
||||
|
||||
// Ticket routes
|
||||
.route("/tickets", web::get().to(TicketController::list_tickets))
|
||||
.route("/tickets/new", web::get().to(TicketController::new_ticket))
|
||||
.route("/tickets/new", web::post().to(TicketController::create_ticket))
|
||||
.route("/tickets/my", web::get().to(TicketController::my_tickets))
|
||||
.route("/tickets/{id}", web::get().to(TicketController::show_ticket))
|
||||
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment))
|
||||
.route("/tickets/{id}/status/{status}", web::get().to(TicketController::update_status))
|
||||
);
|
||||
}
|
39
actix_mvc_app/src/views/auth/login.html
Normal file
39
actix_mvc_app/src/views/auth/login.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Actix MVC App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ email | default(value='') }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Don't have an account? <a href="/register">Register</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
52
actix_mvc_app/src/views/auth/register.html
Normal file
52
actix_mvc_app/src/views/auth/register.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - Actix MVC App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Register</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<ul class="mb-0">
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/register">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ name | default(value='') }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ email | default(value='') }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
<div class="form-text">Password must be at least 8 characters long.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password_confirmation" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="password_confirmation" name="password_confirmation" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Register</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Already have an account? <a href="/login">Login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Actix MVC App{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/unpoly@3.7.2/unpoly.min.css">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,7 +16,7 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if active_page == 'home' %}active{% endif %}" href="/">Home</a>
|
||||
</li>
|
||||
@@ -25,6 +26,34 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if active_page == 'contact' %}active{% endif %}" href="/contact">Contact</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if active_page == 'tickets' %}active{% endif %}" href="/tickets">Support Tickets</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if user and user.id %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ user.name }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
|
||||
<li><a class="dropdown-item" href="/tickets/new">New Ticket</a></li>
|
||||
<li><a class="dropdown-item" href="/tickets/my">My Tickets</a></li>
|
||||
{% if user.role == "Admin" %}
|
||||
<li><a class="dropdown-item" href="/admin">Admin Panel</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if active_page == 'login' %}active{% endif %}" href="/login">Login</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if active_page == 'register' %}active{% endif %}" href="/register">Register</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +78,8 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://unpkg.com/unpoly@3.7.2/unpoly.min.js"></script>
|
||||
<script src="https://unpkg.com/unpoly@3.7.2/unpoly-bootstrap5.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
131
actix_mvc_app/src/views/tickets/list.html
Normal file
131
actix_mvc_app/src/views/tickets/list.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Support Tickets - Actix MVC App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Support Tickets</h1>
|
||||
<a href="/tickets/new" class="btn btn-primary">New Ticket</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Filter Tickets</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/tickets" method="get" up-target="#tickets-container" up-transition="cross-fade">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select" id="status" name="status" up-autosubmit>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open" {% if status == "open" %}selected{% endif %}>Open</option>
|
||||
<option value="in_progress" {% if status == "in_progress" %}selected{% endif %}>In Progress</option>
|
||||
<option value="waiting_for_customer" {% if status == "waiting_for_customer" %}selected{% endif %}>Waiting for Customer</option>
|
||||
<option value="resolved" {% if status == "resolved" %}selected{% endif %}>Resolved</option>
|
||||
<option value="closed" {% if status == "closed" %}selected{% endif %}>Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="priority" class="form-label">Priority</label>
|
||||
<select class="form-select" id="priority" name="priority" up-autosubmit>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="low" {% if priority == "low" %}selected{% endif %}>Low</option>
|
||||
<option value="medium" {% if priority == "medium" %}selected{% endif %}>Medium</option>
|
||||
<option value="high" {% if priority == "high" %}selected{% endif %}>High</option>
|
||||
<option value="critical" {% if priority == "critical" %}selected{% endif %}>Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">Search</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="search" name="search" value="{{ search | default(value='') }}" placeholder="Search tickets...">
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tickets-container">
|
||||
{% if tickets | length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr>
|
||||
<td>{{ ticket.id | truncate(length=8) }}</td>
|
||||
<td>
|
||||
<a href="/tickets/{{ ticket.id }}" up-layer="new modal" up-size="large">
|
||||
{{ ticket.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if ticket.status == 'Open' %}bg-danger{% endif %}
|
||||
{% if ticket.status == 'In Progress' %}bg-warning{% endif %}
|
||||
{% if ticket.status == 'Waiting for Customer' %}bg-info{% endif %}
|
||||
{% if ticket.status == 'Resolved' %}bg-success{% endif %}
|
||||
{% if ticket.status == 'Closed' %}bg-secondary{% endif %}
|
||||
">
|
||||
{{ ticket.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if ticket.priority == 'Low' %}bg-success{% endif %}
|
||||
{% if ticket.priority == 'Medium' %}bg-info{% endif %}
|
||||
{% if ticket.priority == 'High' %}bg-warning{% endif %}
|
||||
{% if ticket.priority == 'Critical' %}bg-danger{% endif %}
|
||||
">
|
||||
{{ ticket.priority }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ ticket.created_at | date(format="%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ ticket.updated_at | date(format="%Y-%m-%d %H:%M") }}</td>
|
||||
<td>
|
||||
<a href="/tickets/{{ ticket.id }}" class="btn btn-sm btn-outline-primary" up-layer="new modal" up-size="large">
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<h4 class="alert-heading">No tickets found!</h4>
|
||||
<p>There are no tickets matching your filter criteria. Try adjusting your filters or create a new ticket.</p>
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<a href="/tickets/new" class="btn btn-primary">Create New Ticket</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize Unpoly event handlers
|
||||
up.on('up:fragment:inserted', function(event) {
|
||||
// This will run whenever a fragment is updated via Unpoly
|
||||
console.log('Fragment updated:', event.target);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
89
actix_mvc_app/src/views/tickets/my_tickets.html
Normal file
89
actix_mvc_app/src/views/tickets/my_tickets.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Tickets - Actix MVC App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>My Support Tickets</h1>
|
||||
<a href="/tickets/new" class="btn btn-primary">New Ticket</a>
|
||||
</div>
|
||||
|
||||
<div id="my-tickets-container">
|
||||
{% if tickets | length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr>
|
||||
<td>{{ ticket.id | truncate(length=8) }}</td>
|
||||
<td>
|
||||
<a href="/tickets/{{ ticket.id }}" up-layer="new modal" up-size="large">
|
||||
{{ ticket.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if ticket.status == 'Open' %}bg-danger{% endif %}
|
||||
{% if ticket.status == 'In Progress' %}bg-warning{% endif %}
|
||||
{% if ticket.status == 'Waiting for Customer' %}bg-info{% endif %}
|
||||
{% if ticket.status == 'Resolved' %}bg-success{% endif %}
|
||||
{% if ticket.status == 'Closed' %}bg-secondary{% endif %}
|
||||
">
|
||||
{{ ticket.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if ticket.priority == 'Low' %}bg-success{% endif %}
|
||||
{% if ticket.priority == 'Medium' %}bg-info{% endif %}
|
||||
{% if ticket.priority == 'High' %}bg-warning{% endif %}
|
||||
{% if ticket.priority == 'Critical' %}bg-danger{% endif %}
|
||||
">
|
||||
{{ ticket.priority }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ ticket.created_at | date(format="%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ ticket.updated_at | date(format="%Y-%m-%d %H:%M") }}</td>
|
||||
<td>
|
||||
<a href="/tickets/{{ ticket.id }}" class="btn btn-sm btn-outline-primary" up-layer="new modal" up-size="large">
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<h4 class="alert-heading">No tickets found!</h4>
|
||||
<p>You haven't created any support tickets yet.</p>
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<a href="/tickets/new" class="btn btn-primary">Create Your First Ticket</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize Unpoly event handlers
|
||||
up.on('up:fragment:inserted', function(event) {
|
||||
// This will run whenever a fragment is updated via Unpoly
|
||||
console.log('Fragment updated:', event.target);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
56
actix_mvc_app/src/views/tickets/new.html
Normal file
56
actix_mvc_app/src/views/tickets/new.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}New Support Ticket - Actix MVC App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Create New Support Ticket</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<ul class="mb-0">
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/tickets/new">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="title" name="title" value="{{ form.title | default(value='') }}" required>
|
||||
<div class="form-text">Provide a brief summary of your issue</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="priority" class="form-label">Priority</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low" {% if form.priority == "low" %}selected{% endif %}>Low</option>
|
||||
<option value="medium" {% if form.priority == "medium" or not form.priority %}selected{% endif %}>Medium</option>
|
||||
<option value="high" {% if form.priority == "high" %}selected{% endif %}>High</option>
|
||||
<option value="critical" {% if form.priority == "critical" %}selected{% endif %}>Critical</option>
|
||||
</select>
|
||||
<div class="form-text">Select the priority level for your issue</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="6" required>{{ form.description | default(value='') }}</textarea>
|
||||
<div class="form-text">Please provide detailed information about your issue</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/tickets" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Ticket</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
140
actix_mvc_app/src/views/tickets/show.html
Normal file
140
actix_mvc_app/src/views/tickets/show.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ticket #{{ ticket.id | truncate(length=8) }} - Actix MVC App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" up-main>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Ticket #{{ ticket.id | truncate(length=8) }}</h1>
|
||||
<div>
|
||||
<a href="/tickets" class="btn btn-outline-secondary">Back to Tickets</a>
|
||||
{% if user.role == "Admin" %}
|
||||
<div class="dropdown d-inline-block ms-2">
|
||||
<button class="btn btn-outline-primary dropdown-toggle" type="button" id="statusDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Update Status
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="statusDropdown">
|
||||
<li><a class="dropdown-item" href="/tickets/{{ ticket.id }}/status/open" up-target="#ticket-details" up-transition="cross-fade">Open</a></li>
|
||||
<li><a class="dropdown-item" href="/tickets/{{ ticket.id }}/status/in_progress" up-target="#ticket-details" up-transition="cross-fade">In Progress</a></li>
|
||||
<li><a class="dropdown-item" href="/tickets/{{ ticket.id }}/status/waiting_for_customer" up-target="#ticket-details" up-transition="cross-fade">Waiting for Customer</a></li>
|
||||
<li><a class="dropdown-item" href="/tickets/{{ ticket.id }}/status/resolved" up-target="#ticket-details" up-transition="cross-fade">Resolved</a></li>
|
||||
<li><a class="dropdown-item" href="/tickets/{{ ticket.id }}/status/closed" up-target="#ticket-details" up-transition="cross-fade">Closed</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ticket-details" class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">{{ ticket.title }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
{{ ticket.description | replace(from="\n", to="<br>") | safe }}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5 class="mb-3">Comments</h5>
|
||||
|
||||
{% if comments | length > 0 %}
|
||||
<div class="comments-container">
|
||||
{% for comment in comments %}
|
||||
<div class="comment mb-3 p-3 border rounded {% if comment.is_support_response %}bg-light{% endif %}">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<strong>
|
||||
{% if comment.is_support_response %}
|
||||
<span class="badge bg-info">Support Team</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Customer</span>
|
||||
{% endif %}
|
||||
</strong>
|
||||
<small class="text-muted">{{ comment.created_at | date(format="%Y-%m-%d %H:%M") }}</small>
|
||||
</div>
|
||||
<div>
|
||||
{{ comment.content | replace(from="\n", to="<br>") | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No comments yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/tickets/{{ ticket.id }}/comment" class="mt-4" up-target=".comments-container" up-transition="move-down">
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">Add a Comment</label>
|
||||
<textarea class="form-control" id="content" name="content" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Submit Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Ticket Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Status</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge
|
||||
{% if ticket.status == 'Open' %}bg-danger{% endif %}
|
||||
{% if ticket.status == 'In Progress' %}bg-warning{% endif %}
|
||||
{% if ticket.status == 'Waiting for Customer' %}bg-info{% endif %}
|
||||
{% if ticket.status == 'Resolved' %}bg-success{% endif %}
|
||||
{% if ticket.status == 'Closed' %}bg-secondary{% endif %}
|
||||
">
|
||||
{{ ticket.status }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Priority</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge
|
||||
{% if ticket.priority == 'Low' %}bg-success{% endif %}
|
||||
{% if ticket.priority == 'Medium' %}bg-info{% endif %}
|
||||
{% if ticket.priority == 'High' %}bg-warning{% endif %}
|
||||
{% if ticket.priority == 'Critical' %}bg-danger{% endif %}
|
||||
">
|
||||
{{ ticket.priority }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Created</dt>
|
||||
<dd class="col-sm-8">{{ ticket.created_at | date(format="%Y-%m-%d %H:%M") }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Updated</dt>
|
||||
<dd class="col-sm-8">{{ ticket.updated_at | date(format="%Y-%m-%d %H:%M") }}</dd>
|
||||
|
||||
{% if ticket.assigned_to %}
|
||||
<dt class="col-sm-4">Assigned To</dt>
|
||||
<dd class="col-sm-8">Agent #{{ ticket.assigned_to }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize Unpoly event handlers for ticket view
|
||||
up.on('up:fragment:inserted', function(event) {
|
||||
// This will run whenever a fragment is updated via Unpoly
|
||||
console.log('Fragment updated in ticket view:', event.target);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user