Add a new /classified page that allows editing classifications
This commit is contained in:
parent
6a23483073
commit
b2406dd883
9 changed files with 315 additions and 183 deletions
|
@ -17,7 +17,6 @@
|
||||||
|
|
||||||
- take concrete actions for spam accounts: lock the account, send a warning
|
- take concrete actions for spam accounts: lock the account, send a warning
|
||||||
email, then delete+purge account after some time.
|
email, then delete+purge account after some time.
|
||||||
- allow changing the classification of already-classified users
|
|
||||||
- add backend to store data on garage instead of local files
|
- add backend to store data on garage instead of local files
|
||||||
- replate the `api_token` file with a better mechanism: oauth maybe?
|
- replate the `api_token` file with a better mechanism: oauth maybe?
|
||||||
- improve error handling
|
- improve error handling
|
||||||
|
|
File diff suppressed because one or more lines are too long
12
src/db.rs
12
src/db.rs
|
@ -78,7 +78,8 @@ impl Db {
|
||||||
|
|
||||||
pub fn store_to_path(&self, path: &Path) -> anyhow::Result<()> {
|
pub fn store_to_path(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
(&self.users, &self.is_spam);
|
let dat: (&HashMap<UserId, UserData>, &HashMap<UserId, bool>, u64) =
|
||||||
|
(&self.users, &self.is_spam, self.last_scrape);
|
||||||
serde_json::to_writer(BufWriter::new(file), &dat)?;
|
serde_json::to_writer(BufWriter::new(file), &dat)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -89,4 +90,13 @@ impl Db {
|
||||||
.filter(|(user_id, _)| !self.is_spam.contains_key(&user_id))
|
.filter(|(user_id, _)| !self.is_spam.contains_key(&user_id))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn classified_users<'a>(&'a self) -> Vec<(&'a UserId, &'a UserData, bool)> {
|
||||||
|
self.users
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(user_id, user_data)|
|
||||||
|
self.is_spam.get(&user_id).map(|is_spam| (user_id, user_data, *is_spam))
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
132
src/main.rs
132
src/main.rs
|
@ -2,7 +2,7 @@ use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
|
||||||
use forgejo_api::{Auth, Forgejo};
|
use forgejo_api::{Auth, Forgejo};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::{Serialize, Deserialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
@ -62,31 +62,57 @@ async fn load_db(forge: &Forgejo) -> anyhow::Result<(Db, Classifier)> {
|
||||||
Ok((db, classifier))
|
Ok((db, classifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_spam(db: &mut Db, classifier: &mut Classifier, ids: &[(UserId, bool)]) {
|
// XXX: This function looks like it is doing too many things at once.
|
||||||
eprintln!("updating classifier");
|
fn set_spam(db: &mut Db, classifier: &mut Classifier, ids: &[(UserId, bool)], overwrite: bool) {
|
||||||
|
|
||||||
for (user_id, is_spam) in ids {
|
for (user_id, is_spam) in ids {
|
||||||
// Train classifier with tokens from the user
|
let mut train_classifier = false;
|
||||||
let tokens = db.tokens.get(user_id).unwrap();
|
|
||||||
if *is_spam {
|
|
||||||
classifier.train_spam(tokens);
|
|
||||||
} else {
|
|
||||||
classifier.train_ham(tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
match db.is_spam.get(user_id) {
|
match db.is_spam.get(user_id) {
|
||||||
Some(b) if b != is_spam => {
|
Some(was_spam) if overwrite && was_spam != is_spam => {
|
||||||
// classification conflict between concurrent queries.
|
eprintln!(
|
||||||
// In this case we play it safe and erase the classification for this user;
|
"User {}: changing classification from {} to {}",
|
||||||
// it will need to be manually classified again.
|
db.users.get(user_id).unwrap().login,
|
||||||
|
(if *was_spam { "spam" } else { "legit" }),
|
||||||
|
(if *is_spam { "spam" } else { "legit" })
|
||||||
|
);
|
||||||
|
db.is_spam.insert(*user_id, *is_spam);
|
||||||
|
// This is somewhat hackish: we already trained the classifier
|
||||||
|
// on the previous classification, possibly with the same
|
||||||
|
// tokens.
|
||||||
|
// Ideally we would undo the previous training and train with
|
||||||
|
// the correct classification now, but the classifier has no way
|
||||||
|
// to easily undo a previous training (we don't know whether the
|
||||||
|
// tokens that we have now are the same as the one that were
|
||||||
|
// used previously).
|
||||||
|
train_classifier = true;
|
||||||
|
},
|
||||||
|
Some(was_spam) if !overwrite && was_spam != is_spam => {
|
||||||
|
// Classification conflict between concurrent queries.
|
||||||
|
// In this case we play it safe and discard the classification
|
||||||
|
// for this user; the user will need to be manually classified again.
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Classification conflict for user {}; discarding our current classification",
|
"Classification conflict for user {}; discarding our current classification",
|
||||||
db.users.get(user_id).unwrap().login
|
db.users.get(user_id).unwrap().login
|
||||||
);
|
);
|
||||||
db.is_spam.remove(user_id);
|
db.is_spam.remove(user_id);
|
||||||
},
|
},
|
||||||
_ => {
|
None => {
|
||||||
db.is_spam.insert(*user_id, *is_spam);
|
db.is_spam.insert(*user_id, *is_spam);
|
||||||
|
train_classifier = true;
|
||||||
|
},
|
||||||
|
Some(was_spam) => {
|
||||||
|
assert!(was_spam == is_spam);
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if train_classifier {
|
||||||
|
// Train the classifier with tokens from the user
|
||||||
|
let tokens = db.tokens.get(user_id).unwrap();
|
||||||
|
if *is_spam {
|
||||||
|
classifier.train_spam(tokens);
|
||||||
|
} else {
|
||||||
|
classifier.train_ham(tokens);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,13 +143,26 @@ struct SortSetting {
|
||||||
sort: Option<String>,
|
sort: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
enum ApproxScore { Low, Mid, High }
|
||||||
|
|
||||||
|
// approximated score, for feeding to the template
|
||||||
|
fn approx_score(score: f32) -> ApproxScore {
|
||||||
|
if score <= GUESS_LEGIT_THRESHOLD {
|
||||||
|
ApproxScore::Low
|
||||||
|
} else if score < GUESS_SPAM_THRESHOLD {
|
||||||
|
ApproxScore::Mid
|
||||||
|
} else {
|
||||||
|
ApproxScore::High
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
async fn index(data: web::Data<AppState>, q: web::Query<SortSetting>) -> impl Responder {
|
async fn index(data: web::Data<AppState>, q: web::Query<SortSetting>) -> impl Responder {
|
||||||
eprintln!("GET /");
|
eprintln!("GET /");
|
||||||
|
|
||||||
let db = &data.db.lock().unwrap();
|
let db = &data.db.lock().unwrap();
|
||||||
|
|
||||||
eprintln!("scoring users...");
|
|
||||||
let mut users: Vec<(&UserId, &UserData, f32)> = db
|
let mut users: Vec<(&UserId, &UserData, f32)> = db
|
||||||
.unclassified_users()
|
.unclassified_users()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@ -131,10 +170,8 @@ async fn index(data: web::Data<AppState>, q: web::Query<SortSetting>) -> impl Re
|
||||||
.collect();
|
.collect();
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
eprintln!("randomizing...");
|
|
||||||
users.shuffle(&mut rng);
|
users.shuffle(&mut rng);
|
||||||
|
|
||||||
eprintln!("sorting...");
|
|
||||||
let sorting_req = q.sort.as_ref().map(|s| s.as_str());
|
let sorting_req = q.sort.as_ref().map(|s| s.as_str());
|
||||||
match &sorting_req {
|
match &sorting_req {
|
||||||
// sort "legit first": by increasing score
|
// sort "legit first": by increasing score
|
||||||
|
@ -150,12 +187,16 @@ async fn index(data: web::Data<AppState>, q: web::Query<SortSetting>) -> impl Re
|
||||||
users.sort_by_key(|(_, _, score)| 1000 - (score * 1000.) as u64)
|
users.sort_by_key(|(_, _, score)| 1000 - (score * 1000.) as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compute the rough "spam score" (low/mid/high) and spam guess (true/false)
|
||||||
|
let users: Vec<(&UserId, &UserData, f32, ApproxScore, bool)> =
|
||||||
|
users.into_iter()
|
||||||
|
.map(|(id, u, score)| (id, u, score, approx_score(score), score >= GUESS_SPAM_THRESHOLD))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let users_count = db.users.len();
|
let users_count = db.users.len();
|
||||||
let classified_count = db.is_spam.len();
|
let classified_count = db.is_spam.len();
|
||||||
|
|
||||||
let mut context = tera::Context::new();
|
let mut context = tera::Context::new();
|
||||||
context.insert("spam_threshold", &GUESS_SPAM_THRESHOLD);
|
|
||||||
context.insert("legit_threshold", &GUESS_LEGIT_THRESHOLD);
|
|
||||||
context.insert("users", &users);
|
context.insert("users", &users);
|
||||||
context.insert(
|
context.insert(
|
||||||
"unclassified_users_count",
|
"unclassified_users_count",
|
||||||
|
@ -168,8 +209,7 @@ async fn index(data: web::Data<AppState>, q: web::Query<SortSetting>) -> impl Re
|
||||||
HttpResponse::Ok().body(page)
|
HttpResponse::Ok().body(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/")]
|
async fn post_classified(data: web::Data<AppState>, req: web::Form<HashMap<i64, String>>, overwrite: bool, redirect: &str) -> impl Responder {
|
||||||
async fn apply(data: web::Data<AppState>, req: web::Form<HashMap<i64, String>>) -> impl Responder {
|
|
||||||
eprintln!("POST /");
|
eprintln!("POST /");
|
||||||
|
|
||||||
let db = &mut data.db.lock().unwrap();
|
let db = &mut data.db.lock().unwrap();
|
||||||
|
@ -180,18 +220,56 @@ async fn apply(data: web::Data<AppState>, req: web::Form<HashMap<i64, String>>)
|
||||||
.map(|(id, classification)| (UserId(*id), classification == "spam"))
|
.map(|(id, classification)| (UserId(*id), classification == "spam"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
set_spam(db, classifier, &updates);
|
set_spam(db, classifier, &updates, overwrite);
|
||||||
|
|
||||||
db.store_to_path(Path::new("db.json")).unwrap(); // FIXME
|
db.store_to_path(Path::new("db.json")).unwrap(); // FIXME
|
||||||
classifier
|
classifier
|
||||||
.save(&mut File::create(Path::new("model.json")).unwrap(), false)
|
.save(&mut File::create(Path::new("model.json")).unwrap(), false)
|
||||||
.unwrap(); // FIXME
|
.unwrap(); // FIXME
|
||||||
|
|
||||||
|
eprintln!("done");
|
||||||
HttpResponse::SeeOther()
|
HttpResponse::SeeOther()
|
||||||
.insert_header(("Location", ""))
|
.insert_header(("Location", redirect))
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/")]
|
||||||
|
async fn post_classified_index(data: web::Data<AppState>, req: web::Form<HashMap<i64, String>>) -> impl Responder {
|
||||||
|
post_classified(data, req, false, "/").await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/classified")]
|
||||||
|
async fn post_classified_edit(data: web::Data<AppState>, req: web::Form<HashMap<i64, String>>) -> impl Responder {
|
||||||
|
post_classified(data, req, true, "/classified").await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/classified")]
|
||||||
|
async fn classified(data: web::Data<AppState>, _q: web::Query<SortSetting>) -> impl Responder {
|
||||||
|
eprintln!("GET /classified");
|
||||||
|
|
||||||
|
let db = &data.db.lock().unwrap();
|
||||||
|
|
||||||
|
let mut users: Vec<(&UserId, &UserData, f32, bool)> = db
|
||||||
|
.classified_users()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, u, s)| (id, u, *db.score.get(id).unwrap(), s))
|
||||||
|
.collect();
|
||||||
|
// sort "spam first"
|
||||||
|
users.sort_by_key(|(_, _, score, _)| 1000 - (score * 1000.) as u64);
|
||||||
|
|
||||||
|
let users: Vec<_> =
|
||||||
|
users.into_iter()
|
||||||
|
.map(|(id, u, score, is_spam)| (id, u, score, approx_score(score), is_spam))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut context = tera::Context::new();
|
||||||
|
context.insert("users", &users);
|
||||||
|
eprintln!("rendering template...");
|
||||||
|
let page = TEMPLATES.render("classified.html", &context).unwrap();
|
||||||
|
eprintln!("done");
|
||||||
|
HttpResponse::Ok().body(page)
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
eprintln!("Eval templates");
|
eprintln!("Eval templates");
|
||||||
|
@ -219,7 +297,9 @@ async fn main() -> std::io::Result<()> {
|
||||||
.service(actix_files::Files::new("/static/", "./static"))
|
.service(actix_files::Files::new("/static/", "./static"))
|
||||||
.app_data(st.clone())
|
.app_data(st.clone())
|
||||||
.service(index)
|
.service(index)
|
||||||
.service(apply)
|
.service(classified)
|
||||||
|
.service(post_classified_index)
|
||||||
|
.service(post_classified_edit)
|
||||||
})
|
})
|
||||||
.bind(("127.0.0.1", 8080))?
|
.bind(("127.0.0.1", 8080))?
|
||||||
.run()
|
.run()
|
||||||
|
|
85
static/style.css
Normal file
85
static/style.css
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px dotted #000;
|
||||||
|
padding: 3px 8px 3px 8px;
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-classification {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.radio-classify {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
input.radio-classify + label {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
input.radio-spam:checked + label {
|
||||||
|
border: 1px solid #d00400;
|
||||||
|
background: #d00400;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
input.radio-legit:checked + label {
|
||||||
|
border: 1px solid #048e02;
|
||||||
|
background: #048e02;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
padding-left: 3px;
|
||||||
|
padding-right: 3px;
|
||||||
|
width: 2.8em;
|
||||||
|
text-align: center;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-High {
|
||||||
|
background: #ff696b;
|
||||||
|
}
|
||||||
|
.score-Mid {
|
||||||
|
background: #ffa769;
|
||||||
|
}
|
||||||
|
.score-Low {
|
||||||
|
background: #5fd770;
|
||||||
|
}
|
16
templates/base.html
Normal file
16
templates/base.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<!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" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="static/style.css" />
|
||||||
|
<title>{% block title %}{% endblock title %} - Forgejo Spam Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="main">
|
||||||
|
{% block content %}
|
||||||
|
{% endblock content %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
29
templates/classified.html
Normal file
29
templates/classified.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{% import "ui.html" as ui %}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Classified{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div>
|
||||||
|
Editing classified users
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="users">
|
||||||
|
{% for user_data in users %}
|
||||||
|
{{ ui::user_card(
|
||||||
|
user_id=user_data[0],
|
||||||
|
user=user_data[1],
|
||||||
|
score=user_data[2],
|
||||||
|
score_approx=user_data[3],
|
||||||
|
is_spam=user_data[4]
|
||||||
|
) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="submit" value="Apply" class="button" style="width: 200px; height: 30px"/>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
|
@ -1,170 +1,35 @@
|
||||||
{% import "macros.html" as macros %}
|
{% import "ui.html" as ui %}
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}Home{% endblock title %}
|
||||||
<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>
|
|
||||||
</head>
|
|
||||||
<style>
|
|
||||||
.main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 30px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px dotted #000;
|
|
||||||
padding: 3px 8px 3px 8px;
|
|
||||||
margin: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
{% block content %}
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-classification {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.radio-classify {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
input.radio-classify + label {
|
|
||||||
border: 1px solid #000;
|
|
||||||
padding: 2px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
input.radio-spam:checked + label {
|
|
||||||
border: 1px solid #d00400;
|
|
||||||
background: #d00400;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
input.radio-legit:checked + label {
|
|
||||||
border: 1px solid #048e02;
|
|
||||||
background: #048e02;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score {
|
|
||||||
padding-left: 3px;
|
|
||||||
padding-right: 3px;
|
|
||||||
width: 2.8em;
|
|
||||||
text-align: center;
|
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-high {
|
|
||||||
background: #ff696b;
|
|
||||||
}
|
|
||||||
.score-mid {
|
|
||||||
background: #ffa769;
|
|
||||||
}
|
|
||||||
.score-low {
|
|
||||||
background: #5fd770;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<body>
|
|
||||||
<div class="main">
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
Users: unclassified: {{unclassified_users_count}} | total: {{total_users_count}}
|
Users: unclassified: {{unclassified_users_count}} | total: {{total_users_count}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="/classified">Edit classified users</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sort-options">
|
<div class="sort-options">
|
||||||
<a href="/?sort=spam">Sort: Spam first</a> |
|
<a href="/?sort=spam">Sort: Spam first</a> |
|
||||||
<a href="/?sort=legit">Sort: Legit first</a> |
|
<a href="/?sort=legit">Sort: Legit first</a> |
|
||||||
<a href="/?sort=random">Sort: Random</a>
|
<a href="/?sort=random">Sort: Random</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="users">
|
<div class="users">
|
||||||
{% for id_user_score in users %}
|
{% for user_data in users %}
|
||||||
{% set user_id = id_user_score[0] %}
|
{{ ui::user_card(
|
||||||
{% set user = id_user_score[1] %}
|
user_id=user_data[0],
|
||||||
{% set score = id_user_score[2] %}
|
user=user_data[1],
|
||||||
<div class="user">
|
score=user_data[2],
|
||||||
<div class="user-classification">
|
score_approx=user_data[3],
|
||||||
<input type="radio" name="{{user_id}}" id="{{user_id}}-spam" value="spam"
|
is_spam=user_data[4]
|
||||||
class="radio-classify radio-spam"
|
) }}
|
||||||
{% if score >= 0.8 %}checked{% endif %}
|
|
||||||
/>
|
|
||||||
<label for="{{user_id}}-spam">Spam</label>
|
|
||||||
<input type="radio" name="{{user_id}}" id="{{user_id}}-legit" value="legit"
|
|
||||||
class="radio-classify radio-legit"
|
|
||||||
{% if score < 0.8 %}checked{% endif %}
|
|
||||||
/>
|
|
||||||
<label for="{{user_id}}-legit">Legit</label>
|
|
||||||
</div>
|
|
||||||
<div class="score
|
|
||||||
{% if score >= spam_threshold %} score-high {% endif %}
|
|
||||||
{% if score < spam_threshold and score > legit_threshold %} score-mid {% endif %}
|
|
||||||
{% if score <= legit_threshold %} score-low {% endif %}
|
|
||||||
">
|
|
||||||
{{ score | round(precision=2) }}
|
|
||||||
</div>
|
|
||||||
<div class="user-card">
|
|
||||||
<div class="user-name">
|
|
||||||
<div><strong><a href="https://git.deuxfleurs.fr/{{user.login}}">{{ user.login }}</a></strong></div>
|
|
||||||
{%- if user.full_name %}<div><strong>({{ user.full_name }})</strong></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 %}
|
{% endfor %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="submit" value="Apply" class="button" style="width: 200px; height: 30px"/>
|
<input type="submit" value="Apply" class="button" style="width: 200px; height: 30px"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
{% endblock content %}
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
48
templates/ui.html
Normal file
48
templates/ui.html
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
{% import "macros.html" as macros %}
|
||||||
|
|
||||||
|
{% macro user_card(user_id, user, score, score_approx, is_spam) %}
|
||||||
|
<div class="user">
|
||||||
|
<div class="user-classification">
|
||||||
|
<input type="radio" name="{{user_id}}" id="{{user_id}}-spam" value="spam"
|
||||||
|
class="radio-classify radio-spam"
|
||||||
|
{% if is_spam %}checked{% endif %}
|
||||||
|
/>
|
||||||
|
<label for="{{user_id}}-spam">Spam</label>
|
||||||
|
<input type="radio" name="{{user_id}}" id="{{user_id}}-legit" value="legit"
|
||||||
|
class="radio-classify radio-legit"
|
||||||
|
{% if not is_spam %}checked{% endif %}
|
||||||
|
/>
|
||||||
|
<label for="{{user_id}}-legit">Legit</label>
|
||||||
|
</div>
|
||||||
|
<div class="score score-{{score_approx}}">
|
||||||
|
{{ score | round(precision=2) }}
|
||||||
|
</div>
|
||||||
|
<div class="user-card">
|
||||||
|
<div class="user-name">
|
||||||
|
<div><strong><a href="https://git.deuxfleurs.fr/{{user.login}}">{{ user.login }}</a></strong></div>
|
||||||
|
{%- if user.full_name %}<div><strong>({{ user.full_name }})</strong></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>
|
||||||
|
{% endmacro user_card %}
|
Loading…
Reference in a new issue