This commit is contained in:
despiegk 2025-04-19 19:46:55 +02:00
parent 9dfe263c60
commit 1b08d57924
18 changed files with 1692 additions and 5 deletions

229
actix_mvc_app/Cargo.lock generated
View File

@ -81,6 +81,22 @@ dependencies = [
"zstd", "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]] [[package]]
name = "actix-macros" name = "actix-macros"
version = "0.2.4" version = "0.2.4"
@ -143,6 +159,23 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "actix-utils" name = "actix-utils"
version = "3.0.1" version = "3.0.1"
@ -213,17 +246,22 @@ name = "actix_mvc_app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"actix-files", "actix-files",
"actix-identity",
"actix-session",
"actix-web", "actix-web",
"bcrypt",
"chrono", "chrono",
"config", "config",
"dotenv", "dotenv",
"env_logger", "env_logger",
"futures", "futures",
"lazy_static",
"log", "log",
"num_cpus", "num_cpus",
"serde", "serde",
"serde_json", "serde_json",
"tera", "tera",
"uuid",
] ]
[[package]] [[package]]
@ -241,6 +279,41 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.11" version = "0.8.11"
@ -348,6 +421,12 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "arraydeque" name = "arraydeque"
version = "0.5.1" version = "0.5.1"
@ -386,6 +465,12 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "base64"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -398,6 +483,19 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.0" version = "2.9.0"
@ -416,6 +514,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "brotli" name = "brotli"
version = "7.0.0" version = "7.0.0"
@ -453,6 +561,12 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.10.1"
@ -522,6 +636,16 @@ dependencies = [
"phf_codegen", "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]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.3" version = "1.0.3"
@ -588,7 +712,14 @@ version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
dependencies = [ dependencies = [
"aes-gcm",
"base64 0.20.0",
"hkdf",
"hmac",
"percent-encoding", "percent-encoding",
"rand 0.8.5",
"sha2",
"subtle",
"time", "time",
"version_check", "version_check",
] ]
@ -655,9 +786,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.0" version = "0.4.0"
@ -715,6 +856,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@ -934,6 +1076,16 @@ dependencies = [
"wasi 0.14.2+wasi-0.2.4", "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]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -1014,6 +1166,24 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 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]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@ -1247,6 +1417,15 @@ dependencies = [
"hashbrown 0.15.2", "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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@ -1476,6 +1655,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "ordered-multimap" name = "ordered-multimap"
version = "0.7.3" version = "0.7.3"
@ -1631,6 +1816,18 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.11.0" version = "1.11.0"
@ -1995,6 +2192,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.100" version = "2.0.100"
@ -2291,6 +2494,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.4"
@ -2320,6 +2533,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 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]] [[package]]
name = "v_htmlescape" name = "v_htmlescape"
version = "0.15.8" version = "0.15.8"
@ -2691,6 +2914,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]] [[package]]
name = "zerovec" name = "zerovec"
version = "0.10.4" version = "0.10.4"

View File

@ -16,3 +16,8 @@ chrono = { version = "0.4.35", features = ["serde"] }
config = "0.14.0" config = "0.14.0"
num_cpus = "1.16.0" num_cpus = "1.16.0"
futures = "0.3.30" 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"

View File

@ -35,7 +35,7 @@ impl AppConfig {
// Set default values // Set default values
let mut config_builder = Config::builder() let mut config_builder = Config::builder()
.set_default("server.host", "127.0.0.1")? .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("server.workers", None::<u32>)?
.set_default("templates.dir", "./src/views")?; .set_default("templates.dir", "./src/views")?;

View 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())
}
}

View File

@ -1,5 +1,9 @@
// Export controllers // Export controllers
pub mod home; pub mod home;
pub mod auth;
pub mod ticket;
// Re-export controllers for easier imports // Re-export controllers for easier imports
pub use home::HomeController; pub use home::HomeController;
pub use auth::AuthController;
pub use ticket::TicketController;

View 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))
}
}

View File

@ -14,6 +14,10 @@ mod utils;
// Import middleware components // Import middleware components
use app_middleware::{RequestTimer, SecurityHeaders}; use app_middleware::{RequestTimer, SecurityHeaders};
// Initialize lazy_static for in-memory storage
#[macro_use]
extern crate lazy_static;
#[actix_web::main] #[actix_web::main]
async fn main() -> io::Result<()> { async fn main() -> io::Result<()> {
// Initialize environment // Initialize environment

View File

@ -1,5 +1,7 @@
// Export models // Export models
pub mod user; pub mod user;
pub mod ticket;
// Re-export models for easier imports // Re-export models for easier imports
pub use user::User; pub use user::User;
pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority, TicketFilter};

View 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,
}
}
}

View File

@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use bcrypt::{hash, verify, DEFAULT_COST};
/// Represents a user in the system /// Represents a user in the system
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -10,6 +11,9 @@ pub struct User {
pub name: String, pub name: String,
/// User's email address /// User's email address
pub email: String, pub email: String,
/// User's hashed password
#[serde(skip_serializing)]
pub password_hash: Option<String>,
/// User's role in the system /// User's role in the system
pub role: UserRole, pub role: UserRole,
/// When the user was created /// When the user was created
@ -34,24 +38,56 @@ impl User {
id: None, id: None,
name, name,
email, email,
password_hash: None,
role: UserRole::User, role: UserRole::User,
created_at: Some(Utc::now()), created_at: Some(Utc::now()),
updated_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 /// Creates a new admin user
pub fn new_admin(name: String, email: String) -> Self { pub fn new_admin(name: String, email: String) -> Self {
Self { Self {
id: None, id: None,
name, name,
email, email,
password_hash: None,
role: UserRole::Admin, role: UserRole::Admin,
created_at: Some(Utc::now()), created_at: Some(Utc::now()),
updated_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 /// Checks if the user is an admin
pub fn is_admin(&self) -> bool { pub fn is_admin(&self) -> bool {
self.role == UserRole::Admin self.role == UserRole::Admin
@ -69,6 +105,38 @@ impl User {
self.updated_at = Some(Utc::now()); 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)] #[cfg(test)]

View File

@ -1,16 +1,45 @@
use actix_web::web; use actix_web::web;
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::cookie::Key;
use crate::controllers::home::HomeController; use crate::controllers::home::HomeController;
use crate::controllers::auth::AuthController;
use crate::controllers::ticket::TicketController;
/// Configures all application routes /// Configures all application routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) { 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( cfg.service(
web::scope("") web::scope("")
.wrap(session_middleware)
// Home routes // Home routes
.route("/", web::get().to(HomeController::index)) .route("/", web::get().to(HomeController::index))
.route("/about", web::get().to(HomeController::about)) .route("/about", web::get().to(HomeController::about))
.route("/contact", web::get().to(HomeController::contact)) .route("/contact", web::get().to(HomeController::contact))
.route("/contact", web::post().to(HomeController::submit_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))
); );
} }

View 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 %}

View 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 %}

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Actix MVC App{% endblock %}</title> <title>{% block title %}Actix MVC App{% endblock %}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css"> <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 %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
@ -15,7 +16,7 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'home' %}active{% endif %}" href="/">Home</a> <a class="nav-link {% if active_page == 'home' %}active{% endif %}" href="/">Home</a>
</li> </li>
@ -25,6 +26,34 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'contact' %}active{% endif %}" href="/contact">Contact</a> <a class="nav-link {% if active_page == 'contact' %}active{% endif %}" href="/contact">Contact</a>
</li> </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> </ul>
</div> </div>
</div> </div>
@ -49,6 +78,8 @@
</footer> </footer>
<script src="/static/js/bootstrap.bundle.min.js"></script> <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 %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View 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 %}

View 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 %}

View 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 %}

View 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 %}