basic web app showing the list of all users

This commit is contained in:
Armaël Guéneau 2024-11-22 10:48:11 +01:00
parent e0a0456402
commit da91785390
6 changed files with 1166 additions and 26 deletions

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
/target /target
classification.json classification.json
model.json db.json
api_token api_token

952
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -15,3 +15,7 @@ anyhow = "1.0.93"
bayespam = "1.1.0" bayespam = "1.1.0"
serde_json = "1.0.133" serde_json = "1.0.133"
rand = "0.8.5" rand = "0.8.5"
actix-web = "4"
tera = "1"
lazy_static = "1.5.0"
actix-files = "0.6.6"

View file

@ -7,6 +7,10 @@ use std::path::Path;
use std::fs::File; use std::fs::File;
use std::io::{BufReader, BufWriter}; use std::io::{BufReader, BufWriter};
use rand::prelude::*; use rand::prelude::*;
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder, HttpRequest};
use tera::Tera;
use lazy_static::lazy_static;
use std::sync::Mutex;
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -37,8 +41,9 @@ struct UserId(i64);
#[derive(Debug)] #[derive(Debug)]
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct UserData { struct UserData {
// login: String, login: String,
email: String, email: String,
full_name: Option<String>,
location: Option<String>, location: Option<String>,
website: Option<String>, website: Option<String>,
description: Option<String>, description: Option<String>,
@ -188,10 +193,10 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
eprintln!("WARN: user with no id"); eprintln!("WARN: user with no id");
continue; continue;
}; };
// let Some(login) = user.login else { let Some(login) = user.login else {
// eprintln!("WARN: missing login for user {id}"); eprintln!("WARN: missing login for user {id}");
// continue; continue;
// }; };
// TODO: fetch those from the admin API instead // TODO: fetch those from the admin API instead
let Some(email) = user.email else { let Some(email) = user.email else {
@ -202,8 +207,9 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
data.insert( data.insert(
UserId(id), UserId(id),
UserData { UserData {
// login, login,
email, email,
full_name: user.full_name,
location: user.location, location: user.location,
website: user.website, website: user.website,
description: user.description, description: user.description,
@ -281,10 +287,9 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
Ok(data) Ok(data)
} }
#[tokio::main] async fn load_db() -> anyhow::Result<(Db, Classifier)> {
async fn main() -> anyhow::Result<()> {
let model_path = Path::new("model.json"); let model_path = Path::new("model.json");
let mut classifier = if model_path.is_file() { let classifier = if model_path.is_file() {
Classifier::new_from_pre_trained(&mut File::open(model_path)?)? Classifier::new_from_pre_trained(&mut File::open(model_path)?)?
} else { } else {
Classifier::new() Classifier::new()
@ -299,7 +304,7 @@ async fn main() -> anyhow::Result<()> {
)?; )?;
let db_path = Path::new("db.json"); let db_path = Path::new("db.json");
let mut db = if db_path.is_file() { let db: Db = if db_path.is_file() {
let file = File::open(db_path)?; let file = File::open(db_path)?;
serde_json::from_reader(BufReader::new(file))? serde_json::from_reader(BufReader::new(file))?
} else { } else {
@ -313,25 +318,24 @@ async fn main() -> anyhow::Result<()> {
let file = File::create(db_path)?; let file = File::create(db_path)?;
serde_json::to_writer(BufWriter::new(file), &db)?; serde_json::to_writer(BufWriter::new(file), &db)?;
db db
}; };
println!("got {} users", db.users.len()); Ok((db, classifier))
let mut users: Vec<_> =
db.users.iter()
.filter_map(
|(user_id, user)|
if db.classification.contains_key(&user_id) {
None
} else {
let text = db.text.get(&user_id).unwrap();
let score = classifier.score(text);
Some((user_id, user, text, score))
} }
)
.collect(); fn unclassified_users<'a>(db: &'a Db, classifier: &Classifier) ->
Vec<(&'a UserId, &'a UserData)>
{
db.users
.iter()
.filter(|(user_id, _)| ! db.classification.contains_key(&user_id))
.collect()
}
/*
async fn main_() -> anyhow::Result<()> {
println!("got {} users", db.users.len());
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
users.shuffle(&mut rng); users.shuffle(&mut rng);
@ -375,3 +379,75 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
*/
lazy_static! {
pub static ref TEMPLATES: Tera = {
match Tera::new("templates/**/*.html") {
Ok(t) => t,
Err(e) => {
println!("Parsing error(s): {}", e);
::std::process::exit(1);
}
}
};
}
struct AppState {
db: Mutex<Db>,
classifier: Mutex<Classifier>,
}
#[get("/")]
async fn index(data: web::Data<AppState>) -> impl Responder {
eprintln!("GET /");
let db = &data.db.lock().unwrap();
let classifier = &data.classifier.lock().unwrap();
eprintln!("compute unclassified users");
let users: Vec<&UserData> =
unclassified_users(db, classifier).into_iter().map(|(_id, u)| u).collect();
let mut context = tera::Context::new();
eprintln!("insert users into tera context");
context.insert("users", &users);
eprintln!("rendering template...");
let page = TEMPLATES.render("index.html", &context).unwrap();
eprintln!("done");
HttpResponse::Ok().body(page)
}
#[post("/")]
async fn apply(data: web::Data<AppState>, req: web::Form<HashMap<String, String>>) -> impl Responder {
println!("{:#?}", req);
HttpResponse::SeeOther().insert_header(("Location", "/")).finish()
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let (db, classifier) = load_db().await.unwrap(); // FIXME
println!("Done loading DB");
let st = web::Data::new(AppState {
db: Mutex::new(db),
classifier: Mutex::new(classifier),
});
println!("Launching web server at http://127.0.0.1:8080...");
HttpServer::new(move || {
App::new()
.service(actix_files::Files::new("/static/", "./static"))
.app_data(st.clone())
.service(index)
.service(apply)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}

98
templates/index.html Normal file
View file

@ -0,0 +1,98 @@
{% import "macros.html" as macros %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Forgejo Spam Admin</title>
<!-- Font Awesome -->
<!-- <link -->
<!-- rel="stylesheet" -->
<!-- href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" -->
<!-- /> -->
<!-- Google Fonts Roboto -->
<!-- <link -->
<!-- rel="stylesheet" -->
<!-- href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap" -->
<!-- /> -->
<!-- MDB -->
<!-- <link rel="stylesheet" href="css/mdb.min.css" /> -->
</head>
<style>
.flex-wrapper {
display: flex;
flex-direction: column;
gap: 15px;
}
.user {
display: flex;
flex-direction: row;
gap: 10px;
}
.user-card {
display: flex;
flex-direction: column;
}
.user-name {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
}
.user-info {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
}
</style>
<body>
<form method="post">
<input type="submit" value="Apply"/>
<div class="flex-wrapper">
{% for user in users %}
<div class="user">
<div class="user-classification">
<input type="checkbox" name="{{user.login}}" style="scale: 1.2"/>
</div>
<div class="user-card">
<div class="user-name">
<div>{{ user.login }}</div>
{%- if user.full_name %}<div>({{ user.full_name }})</div>{% endif -%}
</div>
<div class="user-info">
{%- if user.location %}<div>[L] {{ user.location }}</div>{% endif -%}
{%- if user.website %}<div>[W] {{ user.website }}</div>{% endif -%}
</div>
{%- if user.description %}<div>[D] {{ user.description }}</div>{% endif -%}
{%- if user.repos | length > 0 %}
<div class="user-repos">
<div>Repositories:</div>
{% for repo in user.repos %}
<div>{{ macros::compact(name=repo[1].name, desc=repo[1].description) }}</div>
{% endfor %}
</div>
{% endif -%}
{%- if user.issues | length > 0 %}
<div class="user-issues">
<div>Issues:</div>
{% for issue in user.issues %}
<div>{{ macros::compact(name=issue[1].title, desc=issue[1].body) }}</div>
{% endfor %}
</div>
{% endif -%}
</div>
</div>
{% endfor %}
</div>
</form>
</body>
</html>

10
templates/macros.html Normal file
View file

@ -0,0 +1,10 @@
{% macro compact(name, desc) %}
{% if desc | length <= 150 %}
{{ name }} | {{ desc }}
{% else %}
<details>
<summary>{{ name }} | {{ desc | truncate(length=150) }}</summary>
{{ desc }}
</details>
{% endif %}
{% endmacro compact %}