Merge branch 'development_add_incremental_mode_to_heromodels'
This commit is contained in:
commit
287eca1238
357
heromodels/examples/governance_activity_example.rs
Normal file
357
heromodels/examples/governance_activity_example.rs
Normal file
@ -0,0 +1,357 @@
|
||||
// heromodels/examples/governance_activity_example.rs
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use heromodels::db::{Collection, Db};
|
||||
use heromodels::models::governance::{
|
||||
ActivityType, GovernanceActivity, Proposal, ProposalStatus, VoteEventStatus,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
println!("Governance Activity Tracking Example\n");
|
||||
|
||||
// Create a new DB instance with a unique path, reset before every run
|
||||
let db_path = format!(
|
||||
"/tmp/ourdb_governance_activity_example_{}",
|
||||
chrono::Utc::now().timestamp()
|
||||
);
|
||||
let db = heromodels::db::hero::OurDB::new(&db_path, true).expect("Can create DB");
|
||||
|
||||
// Get collections for proposals and activities
|
||||
let proposal_collection = db
|
||||
.collection::<Proposal>()
|
||||
.expect("can open proposal collection");
|
||||
let activity_collection = db
|
||||
.collection::<GovernanceActivity>()
|
||||
.expect("can open activity collection");
|
||||
|
||||
// === STEP 1: Create a proposal and track the activity ===
|
||||
println!("=== Creating a Proposal ===");
|
||||
|
||||
let now = Utc::now();
|
||||
let mut proposal = Proposal::new(
|
||||
None, // auto-generated ID
|
||||
"user_creator_123",
|
||||
"Alice Johnson",
|
||||
"Community Fund Allocation for Q4",
|
||||
"Proposal to allocate funds for community projects in the fourth quarter.",
|
||||
ProposalStatus::Draft,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
now + Duration::days(14),
|
||||
);
|
||||
|
||||
// Save the proposal
|
||||
let (proposal_id, saved_proposal) = proposal_collection
|
||||
.set(&proposal)
|
||||
.expect("can save proposal");
|
||||
proposal = saved_proposal;
|
||||
|
||||
println!(
|
||||
"Created proposal: '{}' (ID: {})",
|
||||
proposal.title, proposal_id
|
||||
);
|
||||
|
||||
// Track the proposal creation activity
|
||||
let creation_activity = GovernanceActivity::proposal_created(
|
||||
proposal_id,
|
||||
&proposal.title,
|
||||
&proposal.creator_id,
|
||||
&proposal.creator_name,
|
||||
);
|
||||
|
||||
let (activity_id, _) = activity_collection
|
||||
.set(&creation_activity)
|
||||
.expect("can save activity");
|
||||
|
||||
println!(
|
||||
"Tracked activity: Proposal creation (Activity ID: {})",
|
||||
activity_id
|
||||
);
|
||||
|
||||
// === STEP 2: Add vote options and track activities ===
|
||||
println!("\n=== Adding Vote Options ===");
|
||||
|
||||
proposal = proposal.add_option(1, "Approve Allocation", None::<String>);
|
||||
proposal = proposal.add_option(2, "Reject Allocation", None::<String>);
|
||||
proposal = proposal.add_option(3, "Abstain", None::<String>);
|
||||
|
||||
// Track vote option additions
|
||||
for option in &proposal.options {
|
||||
let option_activity = GovernanceActivity::vote_option_added(
|
||||
proposal_id,
|
||||
&proposal.title,
|
||||
option.id,
|
||||
&option.text,
|
||||
Some(&proposal.creator_id),
|
||||
Some(&proposal.creator_name),
|
||||
);
|
||||
|
||||
let (option_activity_id, _) = activity_collection
|
||||
.set(&option_activity)
|
||||
.expect("can save option activity");
|
||||
|
||||
println!(
|
||||
"Added option '{}' and tracked activity (ID: {})",
|
||||
option.text, option_activity_id
|
||||
);
|
||||
}
|
||||
|
||||
// === STEP 3: Start voting and track activity ===
|
||||
println!("\n=== Starting Voting Period ===");
|
||||
|
||||
let voting_start_activity = GovernanceActivity::voting_started(
|
||||
proposal_id,
|
||||
&proposal.title,
|
||||
proposal.vote_start_date,
|
||||
proposal.vote_end_date,
|
||||
);
|
||||
|
||||
let (voting_start_id, _) = activity_collection
|
||||
.set(&voting_start_activity)
|
||||
.expect("can save voting start activity");
|
||||
|
||||
println!("Voting period started (Activity ID: {})", voting_start_id);
|
||||
|
||||
// === STEP 4: Cast votes and track activities ===
|
||||
println!("\n=== Casting Votes ===");
|
||||
|
||||
// Simulate vote casting
|
||||
let votes = vec![
|
||||
(101, 1, 1, 100, "Bob Smith"),
|
||||
(102, 2, 2, 50, "Carol Davis"),
|
||||
(0, 3, 1, 75, "David Wilson"), // auto-generated ballot ID
|
||||
(0, 4, 3, 20, "Eve Brown"), // auto-generated ballot ID
|
||||
];
|
||||
|
||||
for (ballot_id, user_id, option_id, shares, voter_name) in votes {
|
||||
// Cast the vote
|
||||
proposal = proposal.cast_vote(
|
||||
if ballot_id == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(ballot_id)
|
||||
},
|
||||
user_id,
|
||||
option_id,
|
||||
shares,
|
||||
);
|
||||
|
||||
// Find the option text
|
||||
let option_text = proposal
|
||||
.options
|
||||
.iter()
|
||||
.find(|opt| opt.id == option_id)
|
||||
.map(|opt| opt.text.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
// Track the vote casting activity
|
||||
let vote_activity = GovernanceActivity::vote_cast(
|
||||
proposal_id,
|
||||
&proposal.title,
|
||||
ballot_id,
|
||||
user_id.to_string(),
|
||||
voter_name,
|
||||
&option_text,
|
||||
shares,
|
||||
);
|
||||
|
||||
let (vote_activity_id, _) = activity_collection
|
||||
.set(&vote_activity)
|
||||
.expect("can save vote activity");
|
||||
|
||||
println!(
|
||||
"{} voted '{}' with {} shares (Activity ID: {})",
|
||||
voter_name, option_text, shares, vote_activity_id
|
||||
);
|
||||
}
|
||||
|
||||
// === STEP 5: Change proposal status and track activity ===
|
||||
println!("\n=== Changing Proposal Status ===");
|
||||
|
||||
let old_status = format!("{:?}", proposal.status);
|
||||
proposal = proposal.change_proposal_status(ProposalStatus::Active);
|
||||
let new_status = format!("{:?}", proposal.status);
|
||||
|
||||
let status_change_activity = GovernanceActivity::proposal_status_changed(
|
||||
proposal_id,
|
||||
&proposal.title,
|
||||
&old_status,
|
||||
&new_status,
|
||||
Some("admin_user"),
|
||||
Some("Admin User"),
|
||||
);
|
||||
|
||||
let (status_activity_id, _) = activity_collection
|
||||
.set(&status_change_activity)
|
||||
.expect("can save status change activity");
|
||||
|
||||
println!(
|
||||
"Proposal status changed from {} to {} (Activity ID: {})",
|
||||
old_status, new_status, status_activity_id
|
||||
);
|
||||
|
||||
// === STEP 6: End voting and track activity ===
|
||||
println!("\n=== Ending Voting Period ===");
|
||||
|
||||
proposal = proposal.change_vote_event_status(VoteEventStatus::Closed);
|
||||
|
||||
let total_votes = proposal.ballots.len();
|
||||
let total_shares: i64 = proposal.ballots.iter().map(|b| b.shares_count).sum();
|
||||
|
||||
let voting_end_activity =
|
||||
GovernanceActivity::voting_ended(proposal_id, &proposal.title, total_votes, total_shares);
|
||||
|
||||
let (voting_end_id, _) = activity_collection
|
||||
.set(&voting_end_activity)
|
||||
.expect("can save voting end activity");
|
||||
|
||||
println!(
|
||||
"Voting period ended. Total votes: {}, Total shares: {} (Activity ID: {})",
|
||||
total_votes, total_shares, voting_end_id
|
||||
);
|
||||
|
||||
// === STEP 7: Display all activities ===
|
||||
println!("\n=== Recent Governance Activities ===");
|
||||
|
||||
// Try to retrieve activities one by one to identify the problematic one
|
||||
println!("Trying to retrieve activities by ID...");
|
||||
for id in 2..=12 {
|
||||
match activity_collection.get_by_id(id) {
|
||||
Ok(Some(activity)) => {
|
||||
println!(
|
||||
"ID {}: {} (Type: {:?})",
|
||||
id, activity.title, activity.activity_type
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("ID {}: Not found", id);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("ID {}: Error - {:?}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_activities = match activity_collection.get_all() {
|
||||
Ok(activities) => {
|
||||
println!("Successfully retrieved {} activities", activities.len());
|
||||
activities
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error retrieving all activities: {:?}", e);
|
||||
println!(
|
||||
"This might be due to enum serialization changes. Continuing with empty list."
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Sort activities by occurred_at timestamp (most recent first)
|
||||
let mut sorted_activities = all_activities;
|
||||
sorted_activities.sort_by(|a, b| b.occurred_at.cmp(&a.occurred_at));
|
||||
|
||||
for (i, activity) in sorted_activities.iter().enumerate() {
|
||||
println!("{}. {}", i + 1, activity.summary());
|
||||
|
||||
// Show additional details for some activities
|
||||
if !activity.metadata.is_empty() {
|
||||
println!(" Details: {}", activity.metadata);
|
||||
}
|
||||
|
||||
if !activity.tags.is_empty() {
|
||||
println!(" Tags: {:?}", activity.tags);
|
||||
}
|
||||
|
||||
println!(
|
||||
" Severity: {:?}, Status: {:?}",
|
||||
activity.severity, activity.status
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
// === STEP 8: Filter activities by type ===
|
||||
println!("=== Vote-Related Activities ===");
|
||||
|
||||
let vote_activities: Vec<_> = sorted_activities
|
||||
.iter()
|
||||
.filter(|activity| {
|
||||
matches!(
|
||||
activity.activity_type,
|
||||
ActivityType::VoteCast
|
||||
| ActivityType::VotingStarted
|
||||
| ActivityType::VotingEnded
|
||||
| ActivityType::VoteOptionAdded
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (i, activity) in vote_activities.iter().enumerate() {
|
||||
println!("{}. {}", i + 1, activity.summary());
|
||||
}
|
||||
|
||||
// // === STEP 9: Filter activities by user - "My Requests" ===
|
||||
// println!("\n=== My Requests (Alice Johnson's Activities) ===");
|
||||
|
||||
// let my_activities: Vec<_> = sorted_activities
|
||||
// .iter()
|
||||
// .filter(|activity| activity.actor_name.as_deref() == Some("Alice Johnson"))
|
||||
// .collect();
|
||||
|
||||
// if my_activities.is_empty() {
|
||||
// println!("No activities found for Alice Johnson");
|
||||
// } else {
|
||||
// for (i, activity) in my_activities.iter().enumerate() {
|
||||
// println!("{}. {}", i + 1, activity.summary());
|
||||
|
||||
// // Show additional details for user's own activities
|
||||
// if !activity.metadata.is_empty() {
|
||||
// println!(" Details: {}", activity.metadata);
|
||||
// }
|
||||
|
||||
// if !activity.tags.is_empty() {
|
||||
// println!(" Tags: {:?}", activity.tags);
|
||||
// }
|
||||
// println!();
|
||||
// }
|
||||
// }
|
||||
|
||||
// === STEP 10: Filter activities by another user ===
|
||||
println!("=== Bob Smith's Activities ===");
|
||||
|
||||
let bob_activities: Vec<_> = sorted_activities
|
||||
.iter()
|
||||
.filter(|activity| activity.actor_name.as_deref() == Some("Bob Smith"))
|
||||
.collect();
|
||||
|
||||
if bob_activities.is_empty() {
|
||||
println!("No activities found for Bob Smith");
|
||||
} else {
|
||||
for (i, activity) in bob_activities.iter().enumerate() {
|
||||
println!("{}. {}", i + 1, activity.summary());
|
||||
}
|
||||
}
|
||||
|
||||
// === STEP 9: Show statistics ===
|
||||
println!("\n=== Activity Statistics ===");
|
||||
println!("Total activities recorded: {}", sorted_activities.len());
|
||||
|
||||
let activity_counts =
|
||||
sorted_activities
|
||||
.iter()
|
||||
.fold(std::collections::HashMap::new(), |mut acc, activity| {
|
||||
*acc.entry(format!("{:?}", activity.activity_type))
|
||||
.or_insert(0) += 1;
|
||||
acc
|
||||
});
|
||||
|
||||
for (activity_type, count) in activity_counts {
|
||||
println!("- {}: {}", activity_type, count);
|
||||
}
|
||||
|
||||
println!("\nExample finished. DB stored at {}", db_path);
|
||||
println!(
|
||||
"To clean up, you can manually delete the directory: {}",
|
||||
db_path
|
||||
);
|
||||
}
|
328
heromodels/examples/marketplace_example.rs
Normal file
328
heromodels/examples/marketplace_example.rs
Normal file
@ -0,0 +1,328 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use heromodels::db::{Collection, Db};
|
||||
use heromodels::models::finance::marketplace::{Bid, Listing, ListingType};
|
||||
use heromodels::models::finance::asset::AssetType;
|
||||
use heromodels_core::Model;
|
||||
|
||||
// Helper function to print listing details
|
||||
fn print_listing_details(listing: &Listing) {
|
||||
println!("\n--- Listing Details ---");
|
||||
println!("ID: {}", listing.get_id());
|
||||
println!("Title: {}", listing.title);
|
||||
println!("Description: {}", listing.description);
|
||||
println!("Asset ID: {}", listing.asset_id);
|
||||
println!("Asset Type: {:?}", listing.asset_type);
|
||||
println!("Seller ID: {}", listing.seller_id);
|
||||
println!("Price: {} {}", listing.price, listing.currency);
|
||||
println!("Listing Type: {:?}", listing.listing_type);
|
||||
println!("Status: {:?}", listing.status);
|
||||
|
||||
if let Some(expires_at) = listing.expires_at {
|
||||
println!("Expires At: {}", expires_at);
|
||||
} else {
|
||||
println!("Expires At: Never");
|
||||
}
|
||||
|
||||
if let Some(sold_at) = listing.sold_at {
|
||||
println!("Sold At: {}", sold_at);
|
||||
}
|
||||
|
||||
if let Some(buyer_id) = &listing.buyer_id {
|
||||
println!("Buyer ID: {}", buyer_id);
|
||||
}
|
||||
|
||||
if let Some(sale_price) = listing.sale_price {
|
||||
println!("Sale Price: {} {}", sale_price, listing.currency);
|
||||
}
|
||||
|
||||
println!("Bids: {}", listing.bids.len());
|
||||
println!("Tags: {:?}", listing.tags);
|
||||
|
||||
if let Some(image_url) = &listing.image_url {
|
||||
println!("Image URL: {}", image_url);
|
||||
}
|
||||
|
||||
println!("Created At: {}", listing.base_data.created_at);
|
||||
println!("Modified At: {}", listing.base_data.modified_at);
|
||||
}
|
||||
|
||||
// Helper function to print bid details
|
||||
fn print_bid_details(bid: &Bid) {
|
||||
println!("\n--- Bid Details ---");
|
||||
println!("Listing ID: {}", bid.listing_id);
|
||||
println!("Bidder ID: {}", bid.bidder_id);
|
||||
println!("Amount: {} {}", bid.amount, bid.currency);
|
||||
println!("Status: {:?}", bid.status);
|
||||
println!("Created At: {}", bid.created_at);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Create a new DB instance in /tmp/marketplace_db, and reset before every run
|
||||
let db = heromodels::db::hero::OurDB::new("/tmp/marketplace_db", true).expect("Can create DB");
|
||||
|
||||
println!("Hero Models - Marketplace Example");
|
||||
println!("================================");
|
||||
|
||||
// Create listings with auto-generated IDs
|
||||
|
||||
// Fixed price listing
|
||||
let fixed_price_listing = Listing::new(
|
||||
None, // Auto-generated ID
|
||||
"Vintage Guitar",
|
||||
"A beautiful vintage guitar in excellent condition",
|
||||
"asset123",
|
||||
AssetType::Erc721, // NFT representing a physical item
|
||||
"seller456",
|
||||
1200.0,
|
||||
"USD",
|
||||
ListingType::FixedPrice,
|
||||
Some(Utc::now() + Duration::days(30)), // Expires in 30 days
|
||||
vec!["music".to_string(), "instrument".to_string(), "vintage".to_string()],
|
||||
Some("https://example.com/images/vintage_guitar.jpg"),
|
||||
);
|
||||
|
||||
// Auction listing
|
||||
let auction_listing = Listing::new(
|
||||
None, // Auto-generated ID
|
||||
"Rare Painting",
|
||||
"A rare painting from the 19th century",
|
||||
"asset789",
|
||||
AssetType::Erc721, // NFT representing a physical item
|
||||
"seller456",
|
||||
5000.0, // Starting price
|
||||
"USD",
|
||||
ListingType::Auction,
|
||||
Some(Utc::now() + Duration::days(7)), // Auction ends in 7 days
|
||||
vec!["art".to_string(), "painting".to_string(), "antique".to_string()],
|
||||
Some("https://example.com/images/rare_painting.jpg"),
|
||||
);
|
||||
|
||||
// Exchange listing
|
||||
let exchange_listing = Listing::new(
|
||||
None, // Auto-generated ID
|
||||
"Digital Artwork NFT",
|
||||
"A unique digital artwork as an NFT",
|
||||
"asset101",
|
||||
AssetType::Erc1155, // Multi-token for digital art
|
||||
"seller789",
|
||||
0.5, // Price in ETH
|
||||
"ETH",
|
||||
ListingType::Exchange,
|
||||
Some(Utc::now() + Duration::days(14)), // Expires in 14 days
|
||||
vec!["digital".to_string(), "nft".to_string(), "art".to_string()],
|
||||
Some("https://example.com/images/digital_artwork.jpg"),
|
||||
);
|
||||
|
||||
// Save all listings to database and get their assigned IDs and updated models
|
||||
let (fixed_price_id, db_fixed_price) = db.collection().expect("can open listing collection").set(&fixed_price_listing).expect("can set listing");
|
||||
let (auction_id, db_auction) = db.collection().expect("can open listing collection").set(&auction_listing).expect("can set listing");
|
||||
let (exchange_id, db_exchange) = db.collection().expect("can open listing collection").set(&exchange_listing).expect("can set listing");
|
||||
|
||||
println!("Fixed Price Listing assigned ID: {}", fixed_price_id);
|
||||
println!("Auction Listing assigned ID: {}", auction_id);
|
||||
println!("Exchange Listing assigned ID: {}", exchange_id);
|
||||
|
||||
// Print all listings retrieved from database
|
||||
println!("\n--- Listings Retrieved from Database ---");
|
||||
println!("\n1. Fixed Price Listing:");
|
||||
print_listing_details(&db_fixed_price);
|
||||
|
||||
println!("\n2. Auction Listing:");
|
||||
print_listing_details(&db_auction);
|
||||
|
||||
println!("\n3. Exchange Listing:");
|
||||
print_listing_details(&db_exchange);
|
||||
|
||||
// Demonstrate working with bids on an auction listing
|
||||
println!("\n--- Working with Bids ---");
|
||||
|
||||
// Create bids for the auction listing
|
||||
let bid1 = Bid::new(
|
||||
auction_id,
|
||||
101, // Bidder ID
|
||||
5200.0,
|
||||
"USD",
|
||||
);
|
||||
|
||||
let bid2 = Bid::new(
|
||||
auction_id,
|
||||
102, // Bidder ID
|
||||
5500.0,
|
||||
"USD",
|
||||
);
|
||||
|
||||
// Print the bids
|
||||
println!("\n1. First Bid:");
|
||||
print_bid_details(&bid1);
|
||||
|
||||
println!("\n2. Second Bid:");
|
||||
print_bid_details(&bid2);
|
||||
|
||||
// Add bids to the auction listing
|
||||
let updated_auction = db_auction
|
||||
.add_bid(bid1.clone())
|
||||
.expect("can add first bid")
|
||||
.add_bid(bid2.clone())
|
||||
.expect("can add second bid");
|
||||
|
||||
// Save the updated auction listing
|
||||
let (_, db_updated_auction) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&updated_auction)
|
||||
.expect("can set updated auction");
|
||||
|
||||
println!("\n3. Auction Listing After Adding Bids:");
|
||||
print_listing_details(&db_updated_auction);
|
||||
|
||||
// Demonstrate retrieving the highest bid
|
||||
if let Some(highest_bid) = db_updated_auction.highest_bid() {
|
||||
println!("\n4. Highest Bid:");
|
||||
print_bid_details(highest_bid);
|
||||
}
|
||||
|
||||
// Demonstrate completing a sale for the fixed price listing
|
||||
println!("\n--- Completing a Sale ---");
|
||||
|
||||
// Complete the fixed price listing sale
|
||||
let completed_fixed_price = db_fixed_price
|
||||
.complete_sale("buyer123", 1200.0)
|
||||
.expect("can complete sale");
|
||||
|
||||
// Save the updated listing
|
||||
let (_, db_completed_fixed_price) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&completed_fixed_price)
|
||||
.expect("can set completed listing");
|
||||
|
||||
println!("\n1. Fixed Price Listing After Sale:");
|
||||
print_listing_details(&db_completed_fixed_price);
|
||||
|
||||
// Demonstrate completing an auction
|
||||
println!("\n--- Completing an Auction ---");
|
||||
|
||||
// Complete the auction with the highest bidder
|
||||
// Store the bidder_id and amount before moving db_updated_auction
|
||||
let bidder_id = db_updated_auction.highest_bid().unwrap().bidder_id;
|
||||
let amount = db_updated_auction.highest_bid().unwrap().amount;
|
||||
|
||||
// Now complete the sale
|
||||
let completed_auction = db_updated_auction
|
||||
.complete_sale(bidder_id.to_string(), amount)
|
||||
.expect("can complete auction");
|
||||
|
||||
// Save the updated auction listing
|
||||
let (_, db_completed_auction) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&completed_auction)
|
||||
.expect("can set completed auction");
|
||||
|
||||
println!("\n1. Auction Listing After Completion:");
|
||||
print_listing_details(&db_completed_auction);
|
||||
|
||||
// Demonstrate cancelling a listing
|
||||
println!("\n--- Cancelling a Listing ---");
|
||||
|
||||
// Cancel the exchange listing
|
||||
let cancelled_exchange = db_exchange
|
||||
.cancel()
|
||||
.expect("can cancel listing");
|
||||
|
||||
// Save the updated listing
|
||||
let (_, db_cancelled_exchange) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&cancelled_exchange)
|
||||
.expect("can set cancelled listing");
|
||||
|
||||
println!("\n1. Exchange Listing After Cancellation:");
|
||||
print_listing_details(&db_cancelled_exchange);
|
||||
|
||||
// Demonstrate checking for expired listings
|
||||
println!("\n--- Checking for Expired Listings ---");
|
||||
|
||||
// Create a listing that's already expired
|
||||
let expired_listing = Listing::new(
|
||||
None, // Auto-generated ID
|
||||
"Already Expired Item",
|
||||
"This item's listing has already expired",
|
||||
"asset202",
|
||||
AssetType::Erc721, // NFT representing a physical item
|
||||
"seller456",
|
||||
50.0,
|
||||
"USD",
|
||||
ListingType::FixedPrice,
|
||||
Some(Utc::now() - Duration::days(1)), // Expired 1 day ago
|
||||
vec!["expired".to_string()],
|
||||
None::<String>,
|
||||
);
|
||||
|
||||
// Save the expired listing
|
||||
let (expired_id, db_expired) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&expired_listing)
|
||||
.expect("can set expired listing");
|
||||
|
||||
println!("Expired Listing assigned ID: {}", expired_id);
|
||||
|
||||
// Check expiration
|
||||
let checked_expired = db_expired.check_expiration();
|
||||
|
||||
// Save the checked listing
|
||||
let (_, db_checked_expired) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&checked_expired)
|
||||
.expect("can set checked listing");
|
||||
|
||||
println!("\n1. Listing After Expiration Check:");
|
||||
print_listing_details(&db_checked_expired);
|
||||
|
||||
// Demonstrate updating listing details
|
||||
println!("\n--- Updating Listing Details ---");
|
||||
|
||||
// Create a new listing to update
|
||||
let listing_to_update = Listing::new(
|
||||
None, // Auto-generated ID
|
||||
"Original Title",
|
||||
"Original description",
|
||||
"asset303",
|
||||
AssetType::Erc20, // Token for a digital asset
|
||||
"seller456",
|
||||
75.0,
|
||||
"USD",
|
||||
ListingType::FixedPrice,
|
||||
Some(Utc::now() + Duration::days(30)),
|
||||
vec!["original".to_string()],
|
||||
None::<String>,
|
||||
);
|
||||
|
||||
// Save the listing
|
||||
let (update_id, db_to_update) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&listing_to_update)
|
||||
.expect("can set listing to update");
|
||||
|
||||
println!("Listing to Update assigned ID: {}", update_id);
|
||||
println!("\n1. Original Listing:");
|
||||
print_listing_details(&db_to_update);
|
||||
|
||||
// Update the listing details
|
||||
let updated_listing = db_to_update
|
||||
.update_details(
|
||||
Some("Updated Title"),
|
||||
Some("Updated description with more details"),
|
||||
Some(85.0),
|
||||
Some("https://example.com/images/updated_image.jpg"),
|
||||
)
|
||||
.add_tags(vec!["updated".to_string(), "premium".to_string()]);
|
||||
|
||||
// Save the updated listing
|
||||
let (_, db_updated_listing) = db.collection()
|
||||
.expect("can open listing collection")
|
||||
.set(&updated_listing)
|
||||
.expect("can set updated listing");
|
||||
|
||||
println!("\n2. Listing After Update:");
|
||||
print_listing_details(&db_updated_listing);
|
||||
|
||||
println!("\n--- Model Information ---");
|
||||
println!("Listing DB Prefix: {}", Listing::db_prefix());
|
||||
}
|
524
heromodels/src/models/governance/activity.rs
Normal file
524
heromodels/src/models/governance/activity.rs
Normal file
@ -0,0 +1,524 @@
|
||||
// heromodels/src/models/governance/activity.rs
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use heromodels_derive::model;
|
||||
use rhai::{CustomType, TypeBuilder};
|
||||
use rhai_autobind_macros::rhai_model_export;
|
||||
use serde::{Deserialize, Serialize};
|
||||
// use std::collections::HashMap;
|
||||
|
||||
use heromodels_core::BaseModelData;
|
||||
|
||||
/// ActivityType defines the different types of governance activities that can be tracked
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ActivityType {
|
||||
ProposalCreated,
|
||||
ProposalStatusChanged,
|
||||
VotingStarted,
|
||||
VotingEnded,
|
||||
VoteCast,
|
||||
VoteOptionAdded,
|
||||
ProposalDeleted,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Default for ActivityType {
|
||||
fn default() -> Self {
|
||||
ActivityType::Custom
|
||||
}
|
||||
}
|
||||
|
||||
/// ActivityStatus defines the status of an activity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ActivityStatus {
|
||||
Pending, // Activity is scheduled but not yet executed
|
||||
InProgress, // Activity is currently being executed
|
||||
Completed, // Activity has been successfully completed
|
||||
Failed, // Activity failed to complete
|
||||
Cancelled, // Activity was cancelled before completion
|
||||
}
|
||||
|
||||
impl Default for ActivityStatus {
|
||||
fn default() -> Self {
|
||||
ActivityStatus::Completed
|
||||
}
|
||||
}
|
||||
|
||||
/// GovernanceActivity represents a single activity or event in the governance system
|
||||
/// This model tracks all significant actions and changes for audit and transparency purposes
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, CustomType)]
|
||||
#[rhai_model_export(db_type = "std::sync::Arc<crate::db::hero::OurDB>")]
|
||||
#[model]
|
||||
pub struct GovernanceActivity {
|
||||
pub base_data: BaseModelData,
|
||||
|
||||
/// Type of activity that occurred
|
||||
pub activity_type: ActivityType,
|
||||
|
||||
/// Status of the activity
|
||||
pub status: ActivityStatus,
|
||||
|
||||
/// ID of the user who initiated this activity (if applicable)
|
||||
pub actor_id: Option<String>,
|
||||
|
||||
/// Name of the user who initiated this activity (for display purposes)
|
||||
pub actor_name: Option<String>,
|
||||
|
||||
/// ID of the target object (e.g., proposal_id, ballot_id, etc.)
|
||||
pub target_id: Option<u32>,
|
||||
|
||||
/// Type of the target object (e.g., "proposal", "ballot", "vote_option")
|
||||
pub target_type: Option<String>,
|
||||
|
||||
/// Title or brief description of the activity
|
||||
pub title: String,
|
||||
|
||||
/// Detailed description of what happened
|
||||
pub description: String,
|
||||
|
||||
/// Additional metadata as a simple string
|
||||
pub metadata: String,
|
||||
|
||||
/// When the activity occurred
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
|
||||
/// When the activity was recorded in the system
|
||||
pub recorded_at: DateTime<Utc>,
|
||||
|
||||
/// Optional reference to related activities
|
||||
pub related_activity_ids: Vec<u32>,
|
||||
|
||||
/// Tags for categorization and filtering
|
||||
pub tags: Vec<String>,
|
||||
|
||||
/// Severity level of the activity (for filtering and alerting)
|
||||
pub severity: ActivitySeverity,
|
||||
|
||||
/// Whether this activity should be publicly visible
|
||||
pub is_public: bool,
|
||||
|
||||
/// Optional expiration date for temporary activities
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// ActivitySeverity defines the importance level of an activity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ActivitySeverity {
|
||||
Low, // Routine activities
|
||||
Normal, // Standard activities
|
||||
High, // Important activities
|
||||
Critical, // Critical activities that require attention
|
||||
}
|
||||
|
||||
impl Default for ActivitySeverity {
|
||||
fn default() -> Self {
|
||||
ActivitySeverity::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl GovernanceActivity {
|
||||
/// Create a new governance activity with auto-generated ID
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - Optional ID for the activity (use None for auto-generated ID)
|
||||
/// * `activity_type` - Type of activity that occurred
|
||||
/// * `title` - Brief title of the activity
|
||||
/// * `description` - Detailed description of the activity
|
||||
/// * `actor_id` - Optional ID of the user who initiated the activity
|
||||
/// * `actor_name` - Optional name of the user who initiated the activity
|
||||
pub fn new(
|
||||
id: Option<u32>,
|
||||
activity_type: ActivityType,
|
||||
title: impl ToString,
|
||||
description: impl ToString,
|
||||
actor_id: Option<impl ToString>,
|
||||
actor_name: Option<impl ToString>,
|
||||
) -> Self {
|
||||
let mut base_data = BaseModelData::new();
|
||||
if let Some(id) = id {
|
||||
base_data.update_id(id);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
Self {
|
||||
base_data,
|
||||
activity_type,
|
||||
status: ActivityStatus::Completed,
|
||||
actor_id: actor_id.map(|id| id.to_string()),
|
||||
actor_name: actor_name.map(|name| name.to_string()),
|
||||
target_id: None,
|
||||
target_type: None,
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
metadata: String::new(),
|
||||
occurred_at: now,
|
||||
recorded_at: now,
|
||||
related_activity_ids: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
severity: ActivitySeverity::Normal,
|
||||
is_public: true,
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the target of this activity
|
||||
pub fn with_target(mut self, target_id: u32, target_type: impl ToString) -> Self {
|
||||
self.target_id = Some(target_id);
|
||||
self.target_type = Some(target_type.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the status of this activity
|
||||
pub fn with_status(mut self, status: ActivityStatus) -> Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the severity of this activity
|
||||
pub fn with_severity(mut self, severity: ActivitySeverity) -> Self {
|
||||
self.severity = severity;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the occurred_at timestamp
|
||||
pub fn with_occurred_at(mut self, occurred_at: DateTime<Utc>) -> Self {
|
||||
self.occurred_at = occurred_at;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add metadata to this activity
|
||||
pub fn with_metadata(mut self, key: impl ToString, value: impl ToString) -> Self {
|
||||
if !self.metadata.is_empty() {
|
||||
self.metadata.push_str(", ");
|
||||
}
|
||||
self.metadata
|
||||
.push_str(&format!("{}={}", key.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a tag to this activity
|
||||
pub fn with_tag(mut self, tag: impl ToString) -> Self {
|
||||
let tag_str = tag.to_string();
|
||||
if !self.tags.contains(&tag_str) {
|
||||
self.tags.push(tag_str);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple tags to this activity
|
||||
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
|
||||
for tag in tags {
|
||||
if !self.tags.contains(&tag) {
|
||||
self.tags.push(tag);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether this activity is public
|
||||
pub fn with_visibility(mut self, is_public: bool) -> Self {
|
||||
self.is_public = is_public;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set an expiration date for this activity
|
||||
pub fn with_expiration(mut self, expires_at: DateTime<Utc>) -> Self {
|
||||
self.expires_at = Some(expires_at);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a related activity ID
|
||||
pub fn with_related_activity(mut self, activity_id: u32) -> Self {
|
||||
if !self.related_activity_ids.contains(&activity_id) {
|
||||
self.related_activity_ids.push(activity_id);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if this activity has expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
if let Some(expires_at) = self.expires_at {
|
||||
Utc::now() > expires_at
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a formatted summary of this activity
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"[{}] {} - {} (by {})",
|
||||
self.occurred_at.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
self.title,
|
||||
self.description,
|
||||
self.actor_name.as_deref().unwrap_or("System")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory methods for creating common governance activities
|
||||
impl GovernanceActivity {
|
||||
/// Create an activity for proposal creation
|
||||
pub fn proposal_created(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
creator_id: impl ToString,
|
||||
creator_name: impl ToString,
|
||||
) -> Self {
|
||||
let creator_name_str = creator_name.to_string();
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::ProposalCreated,
|
||||
format!("{} created a new proposal", creator_name_str),
|
||||
format!(
|
||||
"{} created a new proposal titled '{}' for community consideration",
|
||||
creator_name_str, proposal_title_str
|
||||
),
|
||||
Some(creator_id),
|
||||
Some(creator_name_str),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_tag("proposal")
|
||||
.with_tag("creation")
|
||||
.with_severity(ActivitySeverity::Normal)
|
||||
}
|
||||
|
||||
/// Create an activity for proposal status change
|
||||
pub fn proposal_status_changed(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
old_status: impl ToString,
|
||||
new_status: impl ToString,
|
||||
actor_id: Option<impl ToString>,
|
||||
actor_name: Option<impl ToString>,
|
||||
) -> Self {
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
let old_status_str = old_status.to_string();
|
||||
let new_status_str = new_status.to_string();
|
||||
let actor_name_str = actor_name
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| "System".to_string());
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::ProposalStatusChanged,
|
||||
format!(
|
||||
"{} changed proposal status to {}",
|
||||
actor_name_str, new_status_str
|
||||
),
|
||||
format!(
|
||||
"{} changed the status of proposal '{}' from {} to {}",
|
||||
actor_name_str, proposal_title_str, old_status_str, new_status_str
|
||||
),
|
||||
actor_id,
|
||||
Some(actor_name_str),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_metadata("old_status", old_status_str)
|
||||
.with_metadata("new_status", new_status_str)
|
||||
.with_tag("proposal")
|
||||
.with_tag("status_change")
|
||||
.with_severity(ActivitySeverity::High)
|
||||
}
|
||||
|
||||
/// Create an activity for vote casting
|
||||
pub fn vote_cast(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
ballot_id: u32,
|
||||
voter_id: impl ToString,
|
||||
voter_name: impl ToString,
|
||||
option_text: impl ToString,
|
||||
shares: i64,
|
||||
) -> Self {
|
||||
let voter_name_str = voter_name.to_string();
|
||||
let option_text_str = option_text.to_string();
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
|
||||
// Create a more natural vote description
|
||||
let vote_description = if option_text_str.to_lowercase().contains("approve")
|
||||
|| option_text_str.to_lowercase().contains("yes")
|
||||
{
|
||||
format!(
|
||||
"{} voted YES on proposal '{}'",
|
||||
voter_name_str, proposal_title_str
|
||||
)
|
||||
} else if option_text_str.to_lowercase().contains("reject")
|
||||
|| option_text_str.to_lowercase().contains("no")
|
||||
{
|
||||
format!(
|
||||
"{} voted NO on proposal '{}'",
|
||||
voter_name_str, proposal_title_str
|
||||
)
|
||||
} else if option_text_str.to_lowercase().contains("abstain") {
|
||||
format!(
|
||||
"{} abstained from voting on proposal '{}'",
|
||||
voter_name_str, proposal_title_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} voted '{}' on proposal '{}'",
|
||||
voter_name_str, option_text_str, proposal_title_str
|
||||
)
|
||||
};
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::VoteCast,
|
||||
format!("{} submitted a vote", voter_name_str),
|
||||
format!("{} with {} voting shares", vote_description, shares),
|
||||
Some(voter_id),
|
||||
Some(voter_name_str),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("ballot_id", ballot_id.to_string())
|
||||
.with_metadata("option_text", option_text_str)
|
||||
.with_metadata("shares", shares.to_string())
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_tag("vote")
|
||||
.with_tag("ballot")
|
||||
.with_severity(ActivitySeverity::Normal)
|
||||
}
|
||||
|
||||
/// Create an activity for voting period start
|
||||
pub fn voting_started(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
start_date: DateTime<Utc>,
|
||||
end_date: DateTime<Utc>,
|
||||
) -> Self {
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::VotingStarted,
|
||||
format!("Voting opened for '{}'", proposal_title_str),
|
||||
format!(
|
||||
"Community voting has opened for proposal '{}' and will close on {}",
|
||||
proposal_title_str,
|
||||
end_date.format("%B %d, %Y at %H:%M UTC")
|
||||
),
|
||||
None::<String>,
|
||||
Some("System"),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_metadata("start_date", start_date.to_rfc3339())
|
||||
.with_metadata("end_date", end_date.to_rfc3339())
|
||||
.with_tag("voting")
|
||||
.with_tag("period_start")
|
||||
.with_severity(ActivitySeverity::High)
|
||||
.with_occurred_at(start_date)
|
||||
}
|
||||
|
||||
/// Create an activity for voting period end
|
||||
pub fn voting_ended(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
total_votes: usize,
|
||||
total_shares: i64,
|
||||
) -> Self {
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::VotingEnded,
|
||||
format!("Voting closed for '{}'", proposal_title_str),
|
||||
format!(
|
||||
"Community voting has ended for proposal '{}'. Final results: {} votes cast representing {} total voting shares",
|
||||
proposal_title_str,
|
||||
total_votes,
|
||||
total_shares
|
||||
),
|
||||
None::<String>,
|
||||
Some("System"),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_metadata("total_votes", total_votes.to_string())
|
||||
.with_metadata("total_shares", total_shares.to_string())
|
||||
.with_tag("voting")
|
||||
.with_tag("period_end")
|
||||
.with_severity(ActivitySeverity::High)
|
||||
}
|
||||
|
||||
/// Create an activity for vote option addition
|
||||
pub fn vote_option_added(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
option_id: u8,
|
||||
option_text: impl ToString,
|
||||
actor_id: Option<impl ToString>,
|
||||
actor_name: Option<impl ToString>,
|
||||
) -> Self {
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
let option_text_str = option_text.to_string();
|
||||
let actor_name_str = actor_name
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| "System".to_string());
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::VoteOptionAdded,
|
||||
format!("{} added a voting option", actor_name_str),
|
||||
format!(
|
||||
"{} added the voting option '{}' to proposal '{}'",
|
||||
actor_name_str, option_text_str, proposal_title_str
|
||||
),
|
||||
actor_id,
|
||||
Some(actor_name_str),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_metadata("option_id", option_id.to_string())
|
||||
.with_metadata("option_text", option_text_str)
|
||||
.with_tag("vote_option")
|
||||
.with_tag("addition")
|
||||
.with_severity(ActivitySeverity::Normal)
|
||||
}
|
||||
|
||||
/// Create an activity for proposal deletion
|
||||
pub fn proposal_deleted(
|
||||
proposal_id: u32,
|
||||
proposal_title: impl ToString,
|
||||
actor_id: impl ToString,
|
||||
actor_name: impl ToString,
|
||||
reason: Option<impl ToString>,
|
||||
) -> Self {
|
||||
let proposal_title_str = proposal_title.to_string();
|
||||
let actor_name_str = actor_name.to_string();
|
||||
|
||||
let description = if let Some(reason) = reason {
|
||||
format!(
|
||||
"{} deleted proposal '{}'. Reason: {}",
|
||||
actor_name_str,
|
||||
proposal_title_str,
|
||||
reason.to_string()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} deleted proposal '{}'",
|
||||
actor_name_str, proposal_title_str
|
||||
)
|
||||
};
|
||||
|
||||
Self::new(
|
||||
None,
|
||||
ActivityType::ProposalDeleted,
|
||||
format!("{} deleted a proposal", actor_name_str),
|
||||
description,
|
||||
Some(actor_id),
|
||||
Some(actor_name_str),
|
||||
)
|
||||
.with_target(proposal_id, "proposal")
|
||||
.with_metadata("proposal_title", proposal_title_str)
|
||||
.with_tag("proposal")
|
||||
.with_tag("deletion")
|
||||
.with_severity(ActivitySeverity::Critical)
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
// heromodels/src/models/governance/mod.rs
|
||||
// This module will contain the Proposal model and related types.
|
||||
pub mod activity;
|
||||
pub mod proposal;
|
||||
|
||||
pub use self::proposal::{
|
||||
Activity, ActivityType, Ballot, Proposal, ProposalStatus, VoteEventStatus, VoteOption,
|
||||
};
|
||||
pub use self::activity::{ActivityStatus, ActivityType, GovernanceActivity};
|
||||
pub use self::proposal::{Ballot, Proposal, ProposalStatus, VoteEventStatus, VoteOption};
|
||||
|
@ -31,3 +31,10 @@ pub use flow::register_flow_rhai_module;
|
||||
pub use legal::register_legal_rhai_module;
|
||||
#[cfg(feature = "rhai")]
|
||||
pub use projects::register_projects_rhai_module;
|
||||
pub use calendar::{AttendanceStatus, Attendee, Calendar, Event};
|
||||
pub use finance::marketplace::{Bid, BidStatus, Listing, ListingStatus, ListingType};
|
||||
pub use finance::{Account, Asset, AssetType};
|
||||
pub use governance::{
|
||||
ActivityStatus, ActivityType, Ballot, GovernanceActivity, Proposal, ProposalStatus,
|
||||
VoteEventStatus, VoteOption,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user