basic web app showing the list of all users
This commit is contained in:
parent
e0a0456402
commit
da91785390
6 changed files with 1166 additions and 26 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,4 +1,4 @@
|
|||
/target
|
||||
classification.json
|
||||
model.json
|
||||
db.json
|
||||
api_token
|
||||
|
|
952
Cargo.lock
generated
952
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -15,3 +15,7 @@ anyhow = "1.0.93"
|
|||
bayespam = "1.1.0"
|
||||
serde_json = "1.0.133"
|
||||
rand = "0.8.5"
|
||||
actix-web = "4"
|
||||
tera = "1"
|
||||
lazy_static = "1.5.0"
|
||||
actix-files = "0.6.6"
|
||||
|
|
126
src/main.rs
126
src/main.rs
|
@ -7,6 +7,10 @@ use std::path::Path;
|
|||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
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(Serialize, Deserialize)]
|
||||
|
@ -37,8 +41,9 @@ struct UserId(i64);
|
|||
#[derive(Debug)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct UserData {
|
||||
// login: String,
|
||||
login: String,
|
||||
email: String,
|
||||
full_name: Option<String>,
|
||||
location: Option<String>,
|
||||
website: 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");
|
||||
continue;
|
||||
};
|
||||
// let Some(login) = user.login else {
|
||||
// eprintln!("WARN: missing login for user {id}");
|
||||
// continue;
|
||||
// };
|
||||
let Some(login) = user.login else {
|
||||
eprintln!("WARN: missing login for user {id}");
|
||||
continue;
|
||||
};
|
||||
|
||||
// TODO: fetch those from the admin API instead
|
||||
let Some(email) = user.email else {
|
||||
|
@ -202,8 +207,9 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
|
|||
data.insert(
|
||||
UserId(id),
|
||||
UserData {
|
||||
// login,
|
||||
login,
|
||||
email,
|
||||
full_name: user.full_name,
|
||||
location: user.location,
|
||||
website: user.website,
|
||||
description: user.description,
|
||||
|
@ -281,10 +287,9 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
|
|||
Ok(data)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
async fn load_db() -> anyhow::Result<(Db, Classifier)> {
|
||||
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)?)?
|
||||
} else {
|
||||
Classifier::new()
|
||||
|
@ -299,7 +304,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
)?;
|
||||
|
||||
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)?;
|
||||
serde_json::from_reader(BufReader::new(file))?
|
||||
} else {
|
||||
|
@ -313,25 +318,24 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let file = File::create(db_path)?;
|
||||
serde_json::to_writer(BufWriter::new(file), &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();
|
||||
users.shuffle(&mut rng);
|
||||
|
@ -375,3 +379,75 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
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
98
templates/index.html
Normal 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
10
templates/macros.html
Normal 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 %}
|
Loading…
Add table
Reference in a new issue