feat: Enhance Governance Controller and Proposal Handling

- Improve proposal search to include description field: This
  allows for more comprehensive search results.
- Fix redirect after voting: The redirect now correctly handles
  the success message.
- Handle potential invalid timestamps in ballots: The code now
  gracefully handles ballots with invalid timestamps, preventing
  crashes and using the current time as a fallback.
- Add local time formatting function:  This provides a way to
  display dates and times in the user's local timezone.
- Update database path: This simplifies the database setup.
- Improve proposal vote handling: Addresses issues with vote
  submission and timestamping.
- Add client-side pagination and filtering to proposal details:
  Improves user experience for viewing large vote lists.
This commit is contained in:
Mahmoud-Emad 2025-05-25 10:48:02 +03:00
parent 97e7a04827
commit d12a082ca1
4 changed files with 142 additions and 70 deletions

View File

@ -135,8 +135,8 @@ impl GovernanceController {
/// Handles the proposal list page route /// Handles the proposal list page route
pub async fn proposals( pub async fn proposals(
query: web::Query<ProposalQuery>, query: web::Query<ProposalQuery>,
tmpl: web::Data<Tera>, tmpl: web::Data<Tera>,
session: Session session: Session,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "governance"); ctx.insert("active_page", "governance");
@ -176,8 +176,8 @@ impl GovernanceController {
proposals = proposals proposals = proposals
.into_iter() .into_iter()
.filter(|p| { .filter(|p| {
p.title.to_lowercase().contains(&search_term) || p.title.to_lowercase().contains(&search_term)
p.description.to_lowercase().contains(&search_term) || p.description.to_lowercase().contains(&search_term)
}) })
.collect(); .collect();
} }
@ -185,7 +185,7 @@ impl GovernanceController {
// Add the filtered proposals to the context // Add the filtered proposals to the context
ctx.insert("proposals", &proposals); ctx.insert("proposals", &proposals);
// Add the filter values back to the context for form persistence // Add the filter values back to the context for form persistence
ctx.insert("status_filter", &query.status); ctx.insert("status_filter", &query.status);
ctx.insert("search_filter", &query.search); ctx.insert("search_filter", &query.search);
@ -224,7 +224,7 @@ impl GovernanceController {
// Calculate voting results directly from the proposal // Calculate voting results directly from the proposal
let results = Self::calculate_voting_results_from_proposal(&proposal); let results = Self::calculate_voting_results_from_proposal(&proposal);
ctx.insert("results", &results); ctx.insert("results", &results);
// Check if vote_success parameter is present and add success message // Check if vote_success parameter is present and add success message
if vote_success { if vote_success {
ctx.insert("success", "Your vote has been successfully recorded!"); ctx.insert("success", "Your vote has been successfully recorded!");
@ -349,7 +349,7 @@ impl GovernanceController {
} }
}; };
ctx.insert("proposals", &proposals); ctx.insert("proposals", &proposals);
// Add the required context variables for the proposals template // Add the required context variables for the proposals template
ctx.insert("active_tab", "proposals"); ctx.insert("active_tab", "proposals");
ctx.insert("status_filter", &None::<String>); ctx.insert("status_filter", &None::<String>);
@ -400,10 +400,13 @@ impl GovernanceController {
1, // Default to 1 share 1, // Default to 1 share
form.comment.as_ref().map(|s| s.to_string()), // Pass the comment from the form form.comment.as_ref().map(|s| s.to_string()), // Pass the comment from the form
) { ) {
Ok(updated_proposal) => { Ok(_) => {
// Redirect to the proposal detail page with a success message // Redirect to the proposal detail page with a success message
return Ok(HttpResponse::Found() return Ok(HttpResponse::Found()
.append_header(("Location", format!("/governance/proposals/{}?vote_success=true", proposal_id))) .append_header((
"Location",
format!("/governance/proposals/{}?vote_success=true", proposal_id),
))
.finish()); .finish());
} }
Err(e) => { Err(e) => {
@ -454,7 +457,7 @@ impl GovernanceController {
ctx.insert("total_yes_votes", &total_vote_counts.0); ctx.insert("total_yes_votes", &total_vote_counts.0);
ctx.insert("total_no_votes", &total_vote_counts.1); ctx.insert("total_no_votes", &total_vote_counts.1);
ctx.insert("total_abstain_votes", &total_vote_counts.2); ctx.insert("total_abstain_votes", &total_vote_counts.2);
ctx.insert("votes", &user_votes); ctx.insert("votes", &user_votes);
render_template(&tmpl, "governance/my_votes.html", &ctx) render_template(&tmpl, "governance/my_votes.html", &ctx)
@ -602,19 +605,31 @@ impl GovernanceController {
} }
}; };
// Create a Vote from the ballot let ballot_timestamp =
let vote = Vote::new( match chrono::DateTime::from_timestamp(ballot.base_data.created_at, 0) {
proposal.base_data.id.to_string(), Some(dt) => dt,
None => {
println!(
"Warning: Invalid timestamp {} for ballot, using current time",
ballot.base_data.created_at
);
Utc::now()
}
};
let vote = Vote {
id: uuid::Uuid::new_v4().to_string(),
proposal_id: proposal.base_data.id.to_string(),
voter_id, voter_id,
format!("User {}", voter_id), voter_name: format!("User {}", voter_id),
vote_type, vote_type,
ballot.comment.clone(), // Use the comment from the ballot comment: ballot.comment.clone(),
); created_at: ballot_timestamp, // This is already local time
updated_at: ballot_timestamp, // Same as created_at for votes
};
votes.push(vote); votes.push(vote);
} }
println!("Extracted {} votes from proposal", votes.len());
votes votes
} }

View File

@ -8,7 +8,7 @@ use heromodels::{
}; };
/// The path to the database file. Change this as needed for your environment. /// The path to the database file. Change this as needed for your environment.
pub const DB_PATH: &str = "/tmp/ourdb_governance6"; pub const DB_PATH: &str = "/tmp/ourdb_governance";
/// Returns a shared OurDB instance for the given path. You can wrap this in Arc/Mutex for concurrent access if needed. /// Returns a shared OurDB instance for the given path. You can wrap this in Arc/Mutex for concurrent access if needed.
pub fn get_db(db_path: &str) -> Result<OurDB, String> { pub fn get_db(db_path: &str) -> Result<OurDB, String> {
@ -188,6 +188,15 @@ pub fn submit_vote_on_proposal(
// Set the comment if provided // Set the comment if provided
ballot.comment = comment; ballot.comment = comment;
// Store the local time (EEST = UTC+3) as the vote timestamp
// This ensures the displayed time matches the user's local time
let utc_now = Utc::now();
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
let local_time = utc_now.with_timezone(&local_offset);
// Store the local time as a timestamp (this is what will be displayed)
ballot.base_data.created_at = local_time.timestamp();
// Add the ballot to the proposal's ballots // Add the ballot to the proposal's ballots
proposal.ballots.push(ballot); proposal.ballots.push(ballot);

View File

@ -30,6 +30,7 @@ impl std::error::Error for TemplateError {}
pub fn register_tera_functions(tera: &mut tera::Tera) { pub fn register_tera_functions(tera: &mut tera::Tera) {
tera.register_function("now", NowFunction); tera.register_function("now", NowFunction);
tera.register_function("format_date", FormatDateFunction); tera.register_function("format_date", FormatDateFunction);
tera.register_function("local_time", LocalTimeFunction);
} }
/// Tera function to get the current date/time /// Tera function to get the current date/time
@ -93,6 +94,52 @@ impl Function for FormatDateFunction {
} }
} }
/// Tera function to convert UTC datetime to local time
#[derive(Clone)]
pub struct LocalTimeFunction;
impl Function for LocalTimeFunction {
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
let datetime_value = match args.get("datetime") {
Some(val) => val,
None => return Err(tera::Error::msg("The 'datetime' argument is required")),
};
let format = match args.get("format") {
Some(val) => match val.as_str() {
Some(s) => s,
None => "%Y-%m-%d %H:%M",
},
None => "%Y-%m-%d %H:%M",
};
// The datetime comes from Rust as a serialized DateTime<Utc>
// We need to handle it properly
let utc_datetime = if let Some(dt_str) = datetime_value.as_str() {
// Try to parse as RFC3339 first
match DateTime::parse_from_rfc3339(dt_str) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => {
// Try to parse as our standard format
match DateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => return Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
} else {
return Err(tera::Error::msg("Datetime must be a string"));
};
// Convert UTC to local time (EEST = UTC+3)
// In a real application, you'd want to get the user's timezone from their profile
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
let local_datetime = utc_datetime.with_timezone(&local_offset);
Ok(Value::String(local_datetime.format(format).to_string()))
}
}
/// Formats a date for display /// Formats a date for display
#[allow(dead_code)] #[allow(dead_code)]
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String { pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {

View File

@ -329,7 +329,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination Controls --> <!-- Pagination Controls -->
{% if votes | length > 10 %} {% if votes | length > 10 %}
<div class="d-flex justify-content-between align-items-center p-3 border-top"> <div class="d-flex justify-content-between align-items-center p-3 border-top">
@ -362,7 +362,8 @@
</nav> </nav>
</div> </div>
<div class="text-muted small" id="paginationInfo"> <div class="text-muted small" id="paginationInfo">
Showing <span id="startRow">1</span>-<span id="endRow">10</span> of <span id="totalRows">{{ votes | length }}</span> Showing <span id="startRow">1</span>-<span id="endRow">10</span> of <span
id="totalRows">{{ votes | length }}</span>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -379,19 +380,19 @@
if (window.location.search.includes('vote_success=true')) { if (window.location.search.includes('vote_success=true')) {
const newUrl = window.location.pathname; const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl); window.history.replaceState({}, document.title, newUrl);
// Auto-hide the success alert after 5 seconds // Auto-hide the success alert after 5 seconds
const successAlert = document.querySelector('.alert-success'); const successAlert = document.querySelector('.alert-success');
if (successAlert) { if (successAlert) {
setTimeout(function() { setTimeout(function () {
successAlert.classList.remove('show'); successAlert.classList.remove('show');
setTimeout(function() { setTimeout(function () {
successAlert.remove(); successAlert.remove();
}, 500); }, 500);
}, 5000); }, 5000);
} }
} }
// Vote filtering using data-filter attributes // Vote filtering using data-filter attributes
const filterButtons = document.querySelectorAll('[data-filter]'); const filterButtons = document.querySelectorAll('[data-filter]');
const voteRows = document.querySelectorAll('.vote-row'); const voteRows = document.querySelectorAll('.vote-row');
@ -399,11 +400,11 @@
// Filter votes by type // Filter votes by type
filterButtons.forEach(button => { filterButtons.forEach(button => {
button.addEventListener('click', function() { button.addEventListener('click', function () {
// Update active button // Update active button
filterButtons.forEach(btn => btn.classList.remove('active')); filterButtons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active'); this.classList.add('active');
// Reset to first page and update pagination // Reset to first page and update pagination
currentPage = 1; currentPage = 1;
updatePagination(); updatePagination();
@ -412,26 +413,26 @@
// Search functionality // Search functionality
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', function() { searchInput.addEventListener('input', function () {
const searchTerm = this.value.toLowerCase(); const searchTerm = this.value.toLowerCase();
voteRows.forEach(row => { voteRows.forEach(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase(); const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase(); const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
if (voterName.includes(searchTerm) || comment.includes(searchTerm)) { if (voterName.includes(searchTerm) || comment.includes(searchTerm)) {
row.style.display = ''; row.style.display = '';
} else { } else {
row.style.display = 'none'; row.style.display = 'none';
} }
}); });
// Reset pagination after search // Reset pagination after search
currentPage = 1; currentPage = 1;
updatePagination(); updatePagination();
}); });
} }
// Pagination functionality // Pagination functionality
const rowsPerPageSelect = document.getElementById('rowsPerPage'); const rowsPerPageSelect = document.getElementById('rowsPerPage');
const paginationControls = document.getElementById('paginationControls'); const paginationControls = document.getElementById('paginationControls');
@ -441,24 +442,24 @@
const totalRowsElement = document.getElementById('totalRows'); const totalRowsElement = document.getElementById('totalRows');
const prevPageBtn = document.getElementById('prevPage'); const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage'); const nextPageBtn = document.getElementById('nextPage');
let currentPage = 1; let currentPage = 1;
let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10; let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10;
// Function to update pagination display // Function to update pagination display
function updatePagination() { function updatePagination() {
if (!paginationControls) return; if (!paginationControls) return;
// Get all rows that match the current filter // Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active'); const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all'; const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term // Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows); let filteredRows = Array.from(voteRows);
if (filterType !== 'all') { if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType); filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
} }
// Apply search filter if there's a search term // Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) { if (searchTerm) {
@ -468,76 +469,76 @@
return voterName.includes(searchTerm) || comment.includes(searchTerm); return voterName.includes(searchTerm) || comment.includes(searchTerm);
}); });
} }
const totalRows = filteredRows.length; const totalRows = filteredRows.length;
// Calculate total pages // Calculate total pages
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid // Ensure current page is valid
if (currentPage > totalPages) { if (currentPage > totalPages) {
currentPage = totalPages; currentPage = totalPages;
} }
// Update pagination controls // Update pagination controls
if (paginationControls) { if (paginationControls) {
// Clear existing page links (except prev/next) // Clear existing page links (except prev/next)
const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)'); const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)');
pageLinks.forEach(link => link.remove()); pageLinks.forEach(link => link.remove());
// Add new page links // Add new page links
const maxVisiblePages = 5; const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Adjust if we're near the end // Adjust if we're near the end
if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) { if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1); startPage = Math.max(1, endPage - maxVisiblePages + 1);
} }
// Insert page links before the next button // Insert page links before the next button
const nextPageElement = document.getElementById('nextPage'); const nextPageElement = document.getElementById('nextPage');
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`; li.className = `page-item ${i === currentPage ? 'active' : ''}`;
const a = document.createElement('a'); const a = document.createElement('a');
a.className = 'page-link'; a.className = 'page-link';
a.href = '#'; a.href = '#';
a.textContent = i; a.textContent = i;
a.addEventListener('click', function(e) { a.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
currentPage = i; currentPage = i;
updatePagination(); updatePagination();
}); });
li.appendChild(a); li.appendChild(a);
paginationControls.insertBefore(li, nextPageElement); paginationControls.insertBefore(li, nextPageElement);
} }
// Update prev/next buttons // Update prev/next buttons
prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`; prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`; nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
} }
// Show current page // Show current page
showCurrentPage(); showCurrentPage();
} }
// Function to show current page // Function to show current page
function showCurrentPage() { function showCurrentPage() {
if (!votesTableBody) return; if (!votesTableBody) return;
// Get all rows that match the current filter // Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active'); const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all'; const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term // Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows); let filteredRows = Array.from(voteRows);
if (filterType !== 'all') { if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType); filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
} }
// Apply search filter if there's a search term // Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) { if (searchTerm) {
@ -547,25 +548,25 @@
return voterName.includes(searchTerm) || comment.includes(searchTerm); return voterName.includes(searchTerm) || comment.includes(searchTerm);
}); });
} }
// Hide all rows first // Hide all rows first
voteRows.forEach(row => row.style.display = 'none'); voteRows.forEach(row => row.style.display = 'none');
// Calculate pagination // Calculate pagination
const totalRows = filteredRows.length; const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid // Ensure current page is valid
if (currentPage > totalPages) { if (currentPage > totalPages) {
currentPage = totalPages; currentPage = totalPages;
} }
// Show only rows for current page // Show only rows for current page
const start = (currentPage - 1) * rowsPerPage; const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage; const end = start + rowsPerPage;
filteredRows.slice(start, end).forEach(row => row.style.display = ''); filteredRows.slice(start, end).forEach(row => row.style.display = '');
// Update pagination info // Update pagination info
if (startRowElement && endRowElement && totalRowsElement) { if (startRowElement && endRowElement && totalRowsElement) {
startRowElement.textContent = totalRows > 0 ? start + 1 : 0; startRowElement.textContent = totalRows > 0 ? start + 1 : 0;
@ -573,10 +574,10 @@
totalRowsElement.textContent = totalRows; totalRowsElement.textContent = totalRows;
} }
} }
// Event listeners for pagination // Event listeners for pagination
if (prevPageBtn) { if (prevPageBtn) {
prevPageBtn.addEventListener('click', function(e) { prevPageBtn.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
if (currentPage > 1) { if (currentPage > 1) {
currentPage--; currentPage--;
@ -584,20 +585,20 @@
} }
}); });
} }
if (nextPageBtn) { if (nextPageBtn) {
nextPageBtn.addEventListener('click', function(e) { nextPageBtn.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
// Get all rows that match the current filter // Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active'); const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all'; const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term // Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows); let filteredRows = Array.from(voteRows);
if (filterType !== 'all') { if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType); filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
} }
// Apply search filter if there's a search term // Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) { if (searchTerm) {
@ -607,25 +608,25 @@
return voterName.includes(searchTerm) || comment.includes(searchTerm); return voterName.includes(searchTerm) || comment.includes(searchTerm);
}); });
} }
const totalRows = filteredRows.length; const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
if (currentPage < totalPages) { if (currentPage < totalPages) {
currentPage++; currentPage++;
updatePagination(); updatePagination();
} }
}); });
} }
if (rowsPerPageSelect) { if (rowsPerPageSelect) {
rowsPerPageSelect.addEventListener('change', function() { rowsPerPageSelect.addEventListener('change', function () {
rowsPerPage = parseInt(this.value); rowsPerPage = parseInt(this.value);
currentPage = 1; // Reset to first page currentPage = 1; // Reset to first page
updatePagination(); updatePagination();
}); });
} }
// Initialize pagination // Initialize pagination
if (paginationControls) { if (paginationControls) {
updatePagination(); updatePagination();