feat: implement reports
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
// coursebank — individual diagnostic template
|
||||
//
|
||||
// This file is a template, not generated output. `coursebank report students`
|
||||
// replaces only the marked regions below and leaves every other line exactly as
|
||||
// you wrote it, so this is where layout decisions belong.
|
||||
//
|
||||
// coursebank template dump --variant student-report
|
||||
// typst watch templates/student-report.typ
|
||||
//
|
||||
// Two markers are in play:
|
||||
//
|
||||
// // coursebank:begin meta course, assessment, class size, policy
|
||||
// // coursebank:end meta
|
||||
// // coursebank:begin data one student's diagnostic
|
||||
// // coursebank:end data
|
||||
//
|
||||
// Both regions ship with sample values, so `typst watch` works before any report
|
||||
// has been generated.
|
||||
//
|
||||
// What is not in the payload: the questions. There is no stem field and no option
|
||||
// text field, on any question, in any configuration. A student report is handed
|
||||
// back before the makeup exam is given, and a report that reproduces the paper
|
||||
// cannot be. If you find yourself wanting to print the question, print its number
|
||||
// and let the student read it off their own copy.
|
||||
|
||||
#import "@preview/mitex:0.2.7": mi
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// coursebank:begin meta
|
||||
#let cb-meta = (
|
||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026f"),
|
||||
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
|
||||
generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"),
|
||||
policy: (mastery-threshold: 0.75, min-items-for-mastery: 2),
|
||||
class-size: 24,
|
||||
extra: (:),
|
||||
)
|
||||
// coursebank:end meta
|
||||
|
||||
// coursebank:begin data
|
||||
#let cb-data = (
|
||||
student-key: "s-000000000000",
|
||||
name: "Sample Student",
|
||||
form: "A",
|
||||
score: (points: 27.0, possible: 36.0, percent: 75.0, bonus: 1.0, correct: 27, items: 36),
|
||||
standing: (class-mean: 71.2, class-sd: 11.4, band: "upper half"),
|
||||
levels: (
|
||||
(level: 1, name: "Remember", blurb: "recalling terms, facts, and definitions",
|
||||
items: 6, rate: 1.0, class-rate: 0.91, comparison: "above the class"),
|
||||
(level: 3, name: "Apply", blurb: "using a procedure in a new situation",
|
||||
items: 9, rate: 0.56, class-rate: 0.64, comparison: "below the class"),
|
||||
),
|
||||
objectives: (
|
||||
(id: "lo-sample-met", text: [A sample objective this student met.], items: 3,
|
||||
credit: 3.0, rate: 1.0, lower: 0.44, upper: 1.0, class-rate: 0.81,
|
||||
status: "meeting", symbol: "✓", confident: false, thin-evidence: false, levels: (1, 2)),
|
||||
(id: "lo-sample-gap", text: [A sample objective to work on.], items: 3,
|
||||
credit: 1.0, rate: 0.33, lower: 0.06, upper: 0.79, class-rate: 0.58,
|
||||
status: "not yet", symbol: "✗", confident: false, thin-evidence: false, levels: (3,)),
|
||||
),
|
||||
strengths: ((id: "lo-sample-met", text: [A sample objective this student met.], rate: 1.0, items: 3),),
|
||||
focus: ((id: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, items: 3),),
|
||||
questions: (
|
||||
(number: 1, level: 1, objectives: ("lo-sample-met",), correct: true, credit: 1.0,
|
||||
bonus: false, blank: false, class-rate: 0.91, taught-in: ()),
|
||||
(number: 2, level: 3, objectives: ("lo-sample-gap",), correct: false, credit: 0.0,
|
||||
bonus: false, blank: false, class-rate: 0.58,
|
||||
feedback: [This is the note written for the option that was chosen.],
|
||||
taught-in: ("Enthalpy (L1.1), slides 12, 13",)),
|
||||
),
|
||||
study: (
|
||||
(objective: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33,
|
||||
readings: (
|
||||
(citation: "KKW §6.2", lecture: "L1.1", lecture-title: "Enthalpy",
|
||||
focus: [What to take from this section.], supplemental: false),
|
||||
)),
|
||||
),
|
||||
)
|
||||
// coursebank:end data
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Settings
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let extra = cb-meta.at("extra", default: (:))
|
||||
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
|
||||
#let body-font = extra.at("font", default: "Roboto")
|
||||
#let body-size = eval(extra.at("font-size", default: "10pt"))
|
||||
#let paper = extra.at("paper", default: "us-letter")
|
||||
|
||||
// Turn sections off from templates/typst.yaml rather than by deleting code, so
|
||||
// a course that does not want the question map keeps the rest of this file.
|
||||
#let show-question-map = extra.at("question-map", default: true)
|
||||
#let show-feedback = extra.at("feedback", default: true)
|
||||
#let show-study = extra.at("study-plan", default: true)
|
||||
#let show-comparison = extra.at("comparison", default: true)
|
||||
|
||||
#let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75)
|
||||
|
||||
#let ok-color = rgb("#2a9d8f")
|
||||
#let mid-color = rgb("#D19F1F")
|
||||
#let bad-color = rgb("#E24E29")
|
||||
#let thin-color = luma(150)
|
||||
|
||||
#set page(
|
||||
paper: paper,
|
||||
margin: (x: 1.9cm, y: 2.1cm),
|
||||
header: text(size: 0.8em, fill: luma(110))[
|
||||
#cb-meta.course.code · #cb-meta.assessment.title · individual diagnostic
|
||||
],
|
||||
footer: context text(size: 0.8em, fill: luma(110))[
|
||||
#h(1fr)
|
||||
Page #counter(page).display("1 of 1", both: true)
|
||||
],
|
||||
)
|
||||
#set text(font: body-font, size: body-size, lang: "en")
|
||||
#set par(justify: false, leading: 0.62em)
|
||||
#show heading.where(level: 1): it => block(above: 1.4em, below: 0.7em)[
|
||||
#text(size: 1.15em, weight: "bold", fill: accent)[#it.body]
|
||||
#v(-0.45em)
|
||||
#line(length: 100%, stroke: 0.6pt + accent.lighten(55%))
|
||||
]
|
||||
#show heading.where(level: 2): it => block(above: 1em, below: 0.45em)[
|
||||
#text(size: 1.02em, weight: "bold")[#it.body]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
|
||||
|
||||
// A rate is a fraction in 0..1; a percent is already out of 100. Keeping the two
|
||||
// straight is the only arithmetic this template does.
|
||||
#let pct(rate) = str(calc.round(rate * 100)) + "%"
|
||||
#let pct1(value) = str(calc.round(value, digits: 0)) + "%"
|
||||
|
||||
#let status-color(status) = {
|
||||
if status == "meeting" { ok-color }
|
||||
else if status == "developing" { mid-color }
|
||||
else if status == "not yet" { bad-color }
|
||||
else { thin-color }
|
||||
}
|
||||
|
||||
#let rate-color(rate) = {
|
||||
if rate >= threshold { ok-color }
|
||||
else if rate >= threshold * 0.6 { mid-color }
|
||||
else { bad-color }
|
||||
}
|
||||
|
||||
#let badge(label, color) = box(
|
||||
fill: color.lighten(82%),
|
||||
radius: 3pt,
|
||||
inset: (x: 5pt, y: 2.5pt),
|
||||
)[#text(size: 0.72em, weight: "bold", fill: color.darken(12%))[#label]]
|
||||
|
||||
// Two boxes side by side rather than an overlay: no `place`, no coordinate
|
||||
// arithmetic, and it degrades to something sensible at any width.
|
||||
#let bar(rate, color: accent, width: 3.6cm) = {
|
||||
let r = calc.max(0.0, calc.min(1.0, rate))
|
||||
box(baseline: 0.15em)[
|
||||
#stack(
|
||||
dir: ltr,
|
||||
box(width: width * r, height: 0.62em, fill: color, radius: (left: 2pt)),
|
||||
box(width: width * (1.0 - r), height: 0.62em, fill: luma(232), radius: (right: 2pt)),
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
#let stat-card(label, value, note: none) = block(
|
||||
width: 100%,
|
||||
fill: luma(247),
|
||||
radius: 4pt,
|
||||
inset: (x: 10pt, y: 9pt),
|
||||
)[
|
||||
#text(size: 0.75em, fill: luma(95))[#upper(label)]
|
||||
#v(0.15em)
|
||||
#text(size: 1.45em, weight: "bold", fill: accent)[#value]
|
||||
#if note != none [
|
||||
#v(0.1em)
|
||||
#text(size: 0.78em, fill: luma(95))[#note]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Heading
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#block(below: 0.6em)[
|
||||
#text(size: 1.5em, weight: "bold")[#cb-meta.assessment.title]
|
||||
#h(0.6em)
|
||||
#text(size: 1em, fill: luma(110))[
|
||||
#cb-meta.course.code — #cb-meta.course.title
|
||||
]
|
||||
]
|
||||
|
||||
#block(below: 1.2em)[
|
||||
#text(size: 0.95em)[
|
||||
*#cb-data.at("name", default: cb-data.student-key)*
|
||||
#{
|
||||
let sid = cb-data.at("sid", default: none)
|
||||
if sid != none [ · #sid ]
|
||||
}
|
||||
#{
|
||||
let form = cb-data.at("form", default: none)
|
||||
if form != none [ · Form #form ]
|
||||
}
|
||||
#{
|
||||
let date = cb-meta.assessment.at("date", default: none)
|
||||
if date != none [ · #date ]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Score
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let score = cb-data.score
|
||||
#let standing = cb-data.at("standing", default: none)
|
||||
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr),
|
||||
gutter: 10pt,
|
||||
stat-card(
|
||||
"score",
|
||||
pct1(score.percent),
|
||||
note: str(score.points) + " of " + str(score.possible) + " points"
|
||||
+ (if score.at("bonus", default: 0.0) > 0.0 { " (+" + str(score.bonus) + " bonus)" } else { "" }),
|
||||
),
|
||||
stat-card(
|
||||
"questions",
|
||||
str(score.correct) + " / " + str(score.items),
|
||||
note: "answered correctly",
|
||||
),
|
||||
if standing != none and show-comparison {
|
||||
stat-card(
|
||||
"class",
|
||||
pct1(standing.class-mean),
|
||||
note: "class average · you are in the " + standing.band,
|
||||
)
|
||||
} else {
|
||||
stat-card("class size", str(cb-meta.at("class-size", default: 0)), note: "students")
|
||||
},
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Levels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if cb-data.levels.len() > 0 [
|
||||
= How you did, by kind of thinking
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Questions are written at levels. Scoring well on recall and less well on
|
||||
application is a different problem from scoring evenly and low, and it has a
|
||||
different fix.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#table(
|
||||
columns: (auto, 1fr, auto, auto, auto),
|
||||
stroke: none,
|
||||
align: (left + horizon, left + horizon, left + horizon, right + horizon, left + horizon),
|
||||
inset: (x: 5pt, y: 5pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[LEVEL],
|
||||
text(size: 0.78em, fill: luma(95))[WHAT IT ASKS],
|
||||
text(size: 0.78em, fill: luma(95))[YOU],
|
||||
text(size: 0.78em, fill: luma(95))[],
|
||||
text(size: 0.78em, fill: luma(95))[CLASS],
|
||||
),
|
||||
..cb-data.levels.map(level => (
|
||||
[*#level.name* #text(size: 0.8em, fill: luma(120))[(#level.items)]],
|
||||
text(size: 0.85em, fill: luma(80))[#level.blurb],
|
||||
bar(level.rate, color: rate-color(level.rate), width: 2.6cm),
|
||||
[#pct(level.rate)],
|
||||
{
|
||||
let class-rate = level.at("class-rate", default: none)
|
||||
if class-rate != none and show-comparison {
|
||||
text(size: 0.85em, fill: luma(100))[#pct(class-rate)]
|
||||
} else { [] }
|
||||
},
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Objectives
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if cb-data.objectives.len() > 0 [
|
||||
= What you have learned, objective by objective
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Each line is one thing the course asked you to be able to do, and how many
|
||||
questions measured it. Two or three questions is thin evidence, so treat a
|
||||
single line as a hint rather than a verdict; the pattern across lines is what
|
||||
to trust.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#table(
|
||||
columns: (auto, 1fr, auto, auto, auto),
|
||||
stroke: none,
|
||||
align: (center + horizon, left + horizon, center + horizon, left + horizon, right + horizon),
|
||||
inset: (x: 5pt, y: 5pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[],
|
||||
text(size: 0.78em, fill: luma(95))[OBJECTIVE],
|
||||
text(size: 0.78em, fill: luma(95))[Q],
|
||||
text(size: 0.78em, fill: luma(95))[YOU],
|
||||
text(size: 0.78em, fill: luma(95))[CLASS],
|
||||
),
|
||||
..cb-data.objectives.map(objective => (
|
||||
text(fill: status-color(objective.status), weight: "bold")[#objective.symbol],
|
||||
{
|
||||
markup(objective.text)
|
||||
if objective.at("thin-evidence", default: false) {
|
||||
[ #badge("too few questions to say", thin-color)]
|
||||
}
|
||||
},
|
||||
text(size: 0.85em, fill: luma(110))[#objective.items],
|
||||
{
|
||||
stack(
|
||||
dir: ltr,
|
||||
spacing: 5pt,
|
||||
bar(objective.rate, color: status-color(objective.status), width: 2.1cm),
|
||||
text(size: 0.9em)[#pct(objective.rate)],
|
||||
)
|
||||
},
|
||||
{
|
||||
let class-rate = objective.at("class-rate", default: none)
|
||||
if class-rate != none and show-comparison {
|
||||
text(size: 0.85em, fill: luma(100))[#pct(class-rate)]
|
||||
} else { [] }
|
||||
},
|
||||
)).flatten(),
|
||||
)
|
||||
|
||||
#v(0.4em)
|
||||
#text(size: 0.78em, fill: luma(110))[
|
||||
#text(fill: ok-color, weight: "bold")[✓] meeting · #text(fill: mid-color, weight: "bold")[~]
|
||||
developing · #text(fill: bad-color, weight: "bold")[✗] not yet ·
|
||||
#text(fill: thin-color, weight: "bold")[?] not enough evidence. The line is
|
||||
drawn at #pct(threshold).
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Strengths and focus
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let strengths = cb-data.at("strengths", default: ())
|
||||
#let focus = cb-data.at("focus", default: ())
|
||||
|
||||
#if strengths.len() > 0 or focus.len() > 0 [
|
||||
= Where to put your time
|
||||
|
||||
#grid(
|
||||
columns: (1fr, 1fr),
|
||||
gutter: 12pt,
|
||||
if focus.len() > 0 {
|
||||
block(
|
||||
width: 100%,
|
||||
fill: bad-color.lighten(93%),
|
||||
radius: 4pt,
|
||||
inset: 10pt,
|
||||
)[
|
||||
#text(weight: "bold", fill: bad-color.darken(15%))[Work on these first]
|
||||
#v(0.35em)
|
||||
#for objective in focus [
|
||||
- #markup(objective.text)
|
||||
#text(size: 0.8em, fill: luma(110))[ (#pct(objective.rate) of #objective.items)]
|
||||
]
|
||||
]
|
||||
} else { [] },
|
||||
if strengths.len() > 0 {
|
||||
block(
|
||||
width: 100%,
|
||||
fill: ok-color.lighten(93%),
|
||||
radius: 4pt,
|
||||
inset: 10pt,
|
||||
)[
|
||||
#text(weight: "bold", fill: ok-color.darken(18%))[Solid, keep it]
|
||||
#v(0.35em)
|
||||
#for objective in strengths [
|
||||
- #markup(objective.text)
|
||||
]
|
||||
]
|
||||
} else { [] },
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Study plan
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let study = cb-data.at("study", default: ())
|
||||
|
||||
#if show-study and study.len() > 0 [
|
||||
= What to read
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
These are the sections behind the objectives above, taken from the course
|
||||
reading list rather than chosen generically.
|
||||
]
|
||||
|
||||
#v(0.4em)
|
||||
|
||||
#for group in study [
|
||||
#block(breakable: false, above: 0.8em)[
|
||||
== #markup(group.text)
|
||||
|
||||
#for reading in group.readings [
|
||||
#block(inset: (left: 0.8em), above: 0.35em)[
|
||||
#{
|
||||
let url = reading.at("url", default: none)
|
||||
let cite = text(weight: "bold")[#reading.citation]
|
||||
if url != none { link(url)[#cite] } else { cite }
|
||||
}
|
||||
#text(size: 0.85em, fill: luma(110))[
|
||||
— #reading.lecture-title (#reading.lecture)#{
|
||||
if reading.at("supplemental", default: false) { ", supplemental" }
|
||||
}
|
||||
]
|
||||
#{
|
||||
let focus-note = reading.at("focus", default: none)
|
||||
if focus-note != none [
|
||||
\ #text(size: 0.9em)[#markup(focus-note)]
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Question map
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let questions = cb-data.at("questions", default: ())
|
||||
|
||||
#let question-box(q) = {
|
||||
let color = if q.at("blank", default: false) { luma(160) }
|
||||
else if q.at("correct", default: false) == true { ok-color }
|
||||
else if q.at("credit", default: 0.0) > 0.0 { mid-color }
|
||||
else { bad-color }
|
||||
box(
|
||||
width: 100%,
|
||||
fill: color.lighten(85%),
|
||||
stroke: 0.5pt + color.lighten(45%),
|
||||
radius: 3pt,
|
||||
inset: (x: 2pt, y: 4pt),
|
||||
)[
|
||||
#align(center)[
|
||||
#text(size: 0.85em, weight: "bold", fill: color.darken(18%))[#q.number]
|
||||
#{
|
||||
let level = q.at("level", default: none)
|
||||
if level != none {
|
||||
linebreak()
|
||||
text(size: 0.62em, fill: luma(110))[L#level]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
#if show-question-map and questions.len() > 0 [
|
||||
= Question by question
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Numbers refer to your own copy of the exam. The questions themselves are not
|
||||
reproduced here.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#grid(
|
||||
columns: (1fr,) * 10,
|
||||
gutter: 4pt,
|
||||
..questions.map(question-box),
|
||||
)
|
||||
|
||||
#v(0.5em)
|
||||
#text(size: 0.78em, fill: luma(110))[
|
||||
#box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) correct ·
|
||||
#box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) partial ·
|
||||
#box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) incorrect ·
|
||||
#box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) blank
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Notes on what was missed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let missed = questions.filter(q =>
|
||||
q.at("credit", default: 0.0) < 0.999 and q.at("feedback", default: none) != none
|
||||
)
|
||||
|
||||
#if show-feedback and missed.len() > 0 [
|
||||
= Notes on the ones you missed
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Each note is written for the specific option you chose, so it names the idea
|
||||
behind that answer rather than restating the right one.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#for q in missed [
|
||||
#block(breakable: false, above: 0.7em, width: 100%)[
|
||||
#grid(
|
||||
columns: (2.6em, 1fr),
|
||||
gutter: 6pt,
|
||||
align(top)[#text(weight: "bold", fill: accent)[#q.number.]],
|
||||
[
|
||||
#markup(q.feedback)
|
||||
#{
|
||||
let taught = q.at("taught-in", default: ())
|
||||
if taught.len() > 0 [
|
||||
\ #text(size: 0.82em, fill: luma(110))[Covered in: #taught.join("; ")]
|
||||
]
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Footer note
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#v(1.2em)
|
||||
#line(length: 100%, stroke: 0.5pt + luma(210))
|
||||
#v(0.4em)
|
||||
#text(size: 0.76em, fill: luma(120))[
|
||||
Generated #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version
|
||||
from #cb-meta.at("class-size", default: 0) student(s). Percentages on a handful of
|
||||
questions carry wide uncertainty; bring this to office hours rather than reading
|
||||
it as a verdict.
|
||||
]
|
||||
Reference in New Issue
Block a user