From 1b08d5792475b93177969689e6fb2bcd068b7c5c Mon Sep 17 00:00:00 2001 From: despiegk Date: Sat, 19 Apr 2025 19:46:55 +0200 Subject: [PATCH] ... --- actix_mvc_app/Cargo.lock | 229 ++++++++ actix_mvc_app/Cargo.toml | 5 + actix_mvc_app/src/config/mod.rs | 2 +- actix_mvc_app/src/controllers/auth.rs | 114 ++++ actix_mvc_app/src/controllers/mod.rs | 6 +- actix_mvc_app/src/controllers/ticket.rs | 517 ++++++++++++++++++ actix_mvc_app/src/main.rs | 4 + actix_mvc_app/src/models/mod.rs | 4 +- actix_mvc_app/src/models/ticket.rs | 177 ++++++ actix_mvc_app/src/models/user.rs | 68 +++ actix_mvc_app/src/routes/mod.rs | 31 +- actix_mvc_app/src/views/auth/login.html | 39 ++ actix_mvc_app/src/views/auth/register.html | 52 ++ actix_mvc_app/src/views/base.html | 33 +- actix_mvc_app/src/views/tickets/list.html | 131 +++++ .../src/views/tickets/my_tickets.html | 89 +++ actix_mvc_app/src/views/tickets/new.html | 56 ++ actix_mvc_app/src/views/tickets/show.html | 140 +++++ 18 files changed, 1692 insertions(+), 5 deletions(-) create mode 100644 actix_mvc_app/src/controllers/auth.rs create mode 100644 actix_mvc_app/src/controllers/ticket.rs create mode 100644 actix_mvc_app/src/models/ticket.rs create mode 100644 actix_mvc_app/src/views/auth/login.html create mode 100644 actix_mvc_app/src/views/auth/register.html create mode 100644 actix_mvc_app/src/views/tickets/list.html create mode 100644 actix_mvc_app/src/views/tickets/my_tickets.html create mode 100644 actix_mvc_app/src/views/tickets/new.html create mode 100644 actix_mvc_app/src/views/tickets/show.html diff --git a/actix_mvc_app/Cargo.lock b/actix_mvc_app/Cargo.lock index ffae781..926819e 100644 --- a/actix_mvc_app/Cargo.lock +++ b/actix_mvc_app/Cargo.lock @@ -81,6 +81,22 @@ dependencies = [ "zstd", ] +[[package]] +name = "actix-identity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e1cc6f95e245b2f3c6995df4e1c0c697704c48c28ec325d135a3ca039d4952" +dependencies = [ + "actix-service", + "actix-session", + "actix-utils", + "actix-web", + "derive_more 0.99.19", + "futures-core", + "serde", + "tracing", +] + [[package]] name = "actix-macros" version = "0.2.4" @@ -143,6 +159,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-session" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6a28f813a6671e1847d005cad0be36ae4d016287690f765c303379837c13d6" +dependencies = [ + "actix-service", + "actix-utils", + "actix-web", + "anyhow", + "async-trait", + "derive_more 0.99.19", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -213,17 +246,22 @@ name = "actix_mvc_app" version = "0.1.0" dependencies = [ "actix-files", + "actix-identity", + "actix-session", "actix-web", + "bcrypt", "chrono", "config", "dotenv", "env_logger", "futures", + "lazy_static", "log", "num_cpus", "serde", "serde_json", "tera", + "uuid", ] [[package]] @@ -241,6 +279,41 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.11" @@ -348,6 +421,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + [[package]] name = "arraydeque" version = "0.5.1" @@ -386,6 +465,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + [[package]] name = "base64" version = "0.21.7" @@ -398,6 +483,19 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.15", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "2.9.0" @@ -416,6 +514,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "brotli" version = "7.0.0" @@ -453,6 +561,12 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -522,6 +636,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -588,7 +712,14 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ + "aes-gcm", + "base64 0.20.0", + "hkdf", + "hmac", "percent-encoding", + "rand 0.8.5", + "sha2", + "subtle", "time", "version_check", ] @@ -655,9 +786,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "deranged" version = "0.4.0" @@ -715,6 +856,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -934,6 +1076,16 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1014,6 +1166,24 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -1247,6 +1417,15 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1476,6 +1655,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -1631,6 +1816,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.0" @@ -1995,6 +2192,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.100" @@ -2291,6 +2494,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.4" @@ -2320,6 +2533,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", + "serde", +] + [[package]] name = "v_htmlescape" version = "0.15.8" @@ -2691,6 +2914,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + [[package]] name = "zerovec" version = "0.10.4" diff --git a/actix_mvc_app/Cargo.toml b/actix_mvc_app/Cargo.toml index 266c86a..0de43cf 100644 --- a/actix_mvc_app/Cargo.toml +++ b/actix_mvc_app/Cargo.toml @@ -16,3 +16,8 @@ chrono = { version = "0.4.35", features = ["serde"] } config = "0.14.0" num_cpus = "1.16.0" futures = "0.3.30" +actix-session = { version = "0.8.0", features = ["cookie-session"] } +actix-identity = "0.6.0" +bcrypt = "0.15.0" +uuid = { version = "1.6.1", features = ["v4", "serde"] } +lazy_static = "1.4.0" diff --git a/actix_mvc_app/src/config/mod.rs b/actix_mvc_app/src/config/mod.rs index 6c18b45..7c1cb06 100644 --- a/actix_mvc_app/src/config/mod.rs +++ b/actix_mvc_app/src/config/mod.rs @@ -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::)? .set_default("templates.dir", "./src/views")?; diff --git a/actix_mvc_app/src/controllers/auth.rs b/actix_mvc_app/src/controllers/auth.rs new file mode 100644 index 0000000..33e1fb0 --- /dev/null +++ b/actix_mvc_app/src/controllers/auth.rs @@ -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) -> Result { + 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, + session: Session, + tmpl: web::Data + ) -> Result { + // 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) -> Result { + 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, + session: Session, + tmpl: web::Data + ) -> Result { + // 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 { + // Clear the session + session.purge(); + + // Redirect to the home page + Ok(HttpResponse::Found() + .append_header(("Location", "/")) + .finish()) + } +} \ No newline at end of file diff --git a/actix_mvc_app/src/controllers/mod.rs b/actix_mvc_app/src/controllers/mod.rs index 8fdeb6f..2932600 100644 --- a/actix_mvc_app/src/controllers/mod.rs +++ b/actix_mvc_app/src/controllers/mod.rs @@ -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; \ No newline at end of file +pub use home::HomeController; +pub use auth::AuthController; +pub use ticket::TicketController; \ No newline at end of file diff --git a/actix_mvc_app/src/controllers/ticket.rs b/actix_mvc_app/src/controllers/ticket.rs new file mode 100644 index 0000000..abd3c6b --- /dev/null +++ b/actix_mvc_app/src/controllers/ticket.rs @@ -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>> = Arc::new(Mutex::new(HashMap::new())); + static ref COMMENTS: Arc>>> = 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, + pub priority: Option, + pub search: Option, +} + +impl TicketController { + /// Lists all tickets with optional filtering + pub async fn list_tickets( + session: Session, + query: web::Query, + tmpl: web::Data + ) -> Result { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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::>() + }; + + // 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 + ) -> Result { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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, + tmpl: web::Data + ) -> Result { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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, + tmpl: web::Data + ) -> Result { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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, + form: web::Form + ) -> Result { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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 { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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 + ) -> Result { + // Get the current user from the session + let user = match session.get::("user")? { + Some(user_json) => { + match serde_json::from_str::(&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::>() + }; + + // 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)) + } +} \ No newline at end of file diff --git a/actix_mvc_app/src/main.rs b/actix_mvc_app/src/main.rs index 90e8055..1dacdb5 100644 --- a/actix_mvc_app/src/main.rs +++ b/actix_mvc_app/src/main.rs @@ -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 diff --git a/actix_mvc_app/src/models/mod.rs b/actix_mvc_app/src/models/mod.rs index c11acd8..722c17b 100644 --- a/actix_mvc_app/src/models/mod.rs +++ b/actix_mvc_app/src/models/mod.rs @@ -1,5 +1,7 @@ // Export models pub mod user; +pub mod ticket; // Re-export models for easier imports -pub use user::User; \ No newline at end of file +pub use user::User; +pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority, TicketFilter}; \ No newline at end of file diff --git a/actix_mvc_app/src/models/ticket.rs b/actix_mvc_app/src/models/ticket.rs new file mode 100644 index 0000000..5e02d91 --- /dev/null +++ b/actix_mvc_app/src/models/ticket.rs @@ -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, + /// When the ticket was last updated + pub updated_at: DateTime, + /// User ID of the assigned support agent (if any) + pub assigned_to: Option, +} + +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, + /// 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, + /// Filter by ticket priority + pub priority: Option, + /// Filter by assigned agent + pub assigned_to: Option, + /// Filter by user who created the ticket + pub user_id: Option, + /// Search term for title and description + pub search_term: Option, +} + +impl Default for TicketFilter { + fn default() -> Self { + Self { + status: None, + priority: None, + assigned_to: None, + user_id: None, + search_term: None, + } + } +} \ No newline at end of file diff --git a/actix_mvc_app/src/models/user.rs b/actix_mvc_app/src/models/user.rs index f156193..1d98865 100644 --- a/actix_mvc_app/src/models/user.rs +++ b/actix_mvc_app/src/models/user.rs @@ -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, /// 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 { + 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 { + 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 { + 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)] diff --git a/actix_mvc_app/src/routes/mod.rs b/actix_mvc_app/src/routes/mod.rs index f22d05f..750de08 100644 --- a/actix_mvc_app/src/routes/mod.rs +++ b/actix_mvc_app/src/routes/mod.rs @@ -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)) ); } \ No newline at end of file diff --git a/actix_mvc_app/src/views/auth/login.html b/actix_mvc_app/src/views/auth/login.html new file mode 100644 index 0000000..39691f1 --- /dev/null +++ b/actix_mvc_app/src/views/auth/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Login - Actix MVC App{% endblock %} + +{% block content %} +
+
+
+
+

Login

+
+
+ {% if error %} + + {% endif %} + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/actix_mvc_app/src/views/auth/register.html b/actix_mvc_app/src/views/auth/register.html new file mode 100644 index 0000000..1c0e91c --- /dev/null +++ b/actix_mvc_app/src/views/auth/register.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block title %}Register - Actix MVC App{% endblock %} + +{% block content %} +
+
+
+
+

Register

+
+
+ {% if errors %} + + {% endif %} + +
+
+ + +
+
+ + +
+
+ + +
Password must be at least 8 characters long.
+
+
+ + +
+
+ +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/actix_mvc_app/src/views/base.html b/actix_mvc_app/src/views/base.html index 7611ad2..9d90d65 100644 --- a/actix_mvc_app/src/views/base.html +++ b/actix_mvc_app/src/views/base.html @@ -5,6 +5,7 @@ {% block title %}Actix MVC App{% endblock %} + {% block extra_css %}{% endblock %} @@ -15,7 +16,7 @@ @@ -49,6 +78,8 @@ + + {% block extra_js %}{% endblock %} \ No newline at end of file diff --git a/actix_mvc_app/src/views/tickets/list.html b/actix_mvc_app/src/views/tickets/list.html new file mode 100644 index 0000000..6d41b22 --- /dev/null +++ b/actix_mvc_app/src/views/tickets/list.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} + +{% block title %}Support Tickets - Actix MVC App{% endblock %} + +{% block content %} +
+

Support Tickets

+ New Ticket +
+ +
+
+
Filter Tickets
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+
+
+ +
+ {% if tickets | length > 0 %} +
+ + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + + + {% endfor %} + +
IDTitleStatusPriorityCreatedUpdatedActions
{{ ticket.id | truncate(length=8) }} + + {{ ticket.title }} + + + + {{ ticket.status }} + + + + {{ ticket.priority }} + + {{ ticket.created_at | date(format="%Y-%m-%d %H:%M") }}{{ ticket.updated_at | date(format="%Y-%m-%d %H:%M") }} + + View + +
+
+ {% else %} +
+

No tickets found!

+

There are no tickets matching your filter criteria. Try adjusting your filters or create a new ticket.

+
+

+ Create New Ticket +

+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/actix_mvc_app/src/views/tickets/my_tickets.html b/actix_mvc_app/src/views/tickets/my_tickets.html new file mode 100644 index 0000000..22d074f --- /dev/null +++ b/actix_mvc_app/src/views/tickets/my_tickets.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block title %}My Tickets - Actix MVC App{% endblock %} + +{% block content %} +
+

My Support Tickets

+ New Ticket +
+ +
+ {% if tickets | length > 0 %} +
+ + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + + + {% endfor %} + +
IDTitleStatusPriorityCreatedUpdatedActions
{{ ticket.id | truncate(length=8) }} + + {{ ticket.title }} + + + + {{ ticket.status }} + + + + {{ ticket.priority }} + + {{ ticket.created_at | date(format="%Y-%m-%d %H:%M") }}{{ ticket.updated_at | date(format="%Y-%m-%d %H:%M") }} + + View + +
+
+ {% else %} +
+

No tickets found!

+

You haven't created any support tickets yet.

+
+

+ Create Your First Ticket +

+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/actix_mvc_app/src/views/tickets/new.html b/actix_mvc_app/src/views/tickets/new.html new file mode 100644 index 0000000..e3a9837 --- /dev/null +++ b/actix_mvc_app/src/views/tickets/new.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}New Support Ticket - Actix MVC App{% endblock %} + +{% block content %} +
+
+
+
+

Create New Support Ticket

+
+
+ {% if errors %} + + {% endif %} + +
+
+ + +
Provide a brief summary of your issue
+
+ +
+ + +
Select the priority level for your issue
+
+ +
+ + +
Please provide detailed information about your issue
+
+ +
+ Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/actix_mvc_app/src/views/tickets/show.html b/actix_mvc_app/src/views/tickets/show.html new file mode 100644 index 0000000..52cb85a --- /dev/null +++ b/actix_mvc_app/src/views/tickets/show.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} + +{% block title %}Ticket #{{ ticket.id | truncate(length=8) }} - Actix MVC App{% endblock %} + +{% block content %} +
+
+

Ticket #{{ ticket.id | truncate(length=8) }}

+
+ Back to Tickets + {% if user.role == "Admin" %} + + {% endif %} +
+
+ +
+
+
+
+
{{ ticket.title }}
+
+
+
+ {{ ticket.description | replace(from="\n", to="
") | safe }} +
+ +
+ +
Comments
+ + {% if comments | length > 0 %} +
+ {% for comment in comments %} +
+
+ + {% if comment.is_support_response %} + Support Team + {% else %} + Customer + {% endif %} + + {{ comment.created_at | date(format="%Y-%m-%d %H:%M") }} +
+
+ {{ comment.content | replace(from="\n", to="
") | safe }} +
+
+ {% endfor %} +
+ {% else %} +
+ No comments yet. +
+ {% endif %} + +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+
Ticket Details
+
+
+
+
Status
+
+ + {{ ticket.status }} + +
+ +
Priority
+
+ + {{ ticket.priority }} + +
+ +
Created
+
{{ ticket.created_at | date(format="%Y-%m-%d %H:%M") }}
+ +
Updated
+
{{ ticket.updated_at | date(format="%Y-%m-%d %H:%M") }}
+ + {% if ticket.assigned_to %} +
Assigned To
+
Agent #{{ ticket.assigned_to }}
+ {% endif %} +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file