diff options
29 files changed, 1088 insertions, 257 deletions
diff --git a/app/admin/forms.py b/app/admin/forms.py index 1ac9333..bd1dce6 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -22,7 +22,7 @@ class AdminAutoAssignForm(FlaskForm): num_per_dataset = IntegerField( "Tasks per Dataset", [NumberRange(min=1, max=20)], default=10 ) - assign = SubmitField("Assign") + submit = SubmitField("Submit") class AdminManageTaskForm(FlaskForm): diff --git a/app/admin/routes.py b/app/admin/routes.py index efb4f57..f0d7817 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -41,7 +41,7 @@ def manage_tasks(): form_manual.username.choices = user_list form_manual.dataset.choices = dataset_list - if form_auto.validate_on_submit() and form_auto.assign.data: + if form_auto.validate_on_submit() and form_auto.submit.data: max_per_user = form_auto.max_per_user.data num_per_dataset = form_auto.num_per_dataset.data @@ -130,6 +130,8 @@ def manage_users(): flash("User doesn't exist.", "error") return redirect(url_for("admin.manage_users")) + username = user.username + tasks = Task.query.filter_by(annotator_id=user.id).all() for task in tasks: for ann in Annotation.query.filter_by(task_id=task.id).all(): @@ -137,7 +139,7 @@ def manage_users(): db.session.delete(task) db.session.delete(user) db.session.commit() - flash("User deleted successfully.", "success") + flash("User '%s' deleted successfully." % username, "success") return redirect(url_for("admin.manage_users")) return render_template( "admin/manage_users.html", title="Manage Users", users=users, form=form @@ -175,6 +177,7 @@ def manage_datasets(): db.session.commit() os.unlink(filename) flash("Dataset deleted successfully.", "success") + return redirect(url_for("admin.manage_datasets")) overview = [] for dataset in Dataset.query.all(): @@ -187,11 +190,13 @@ def manage_datasets(): entry = { "id": dataset.id, "name": dataset.name, + "demo": dataset.is_demo, "assigned": len(tasks), "completed": n_complete, "percentage": perc, } overview.append(entry) + overview.sort(key=lambda x: x["name"]) return render_template( "admin/manage_datasets.html", title="Manage Datasets", @@ -226,8 +231,10 @@ def add_dataset(): if not os.path.exists(target_filename): flash("Internal error: file moving failed", "error") return redirect(url_for("admin.add_dataset")) - - dataset = Dataset(name=name, md5sum=md5sum(target_filename)) + is_demo = dataset_is_demo(target_filename) + dataset = Dataset( + name=name, md5sum=md5sum(target_filename), is_demo=is_demo + ) db.session.add(dataset) db.session.commit() flash("Dataset %r added successfully." % name, "success") diff --git a/app/auth/routes.py b/app/auth/routes.py index d23bc18..27b0de0 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -21,15 +21,10 @@ from app.auth.email import ( send_email_confirmation_email, ) from app.models import User -from app.utils.tasks import create_initial_user_tasks @bp.route("/login", methods=("GET", "POST")) def login(): - if current_user.is_authenticated: - current_user.last_active = datetime.datetime.utcnow() - db.session.commit() - return redirect(url_for("main.index")) form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() @@ -37,6 +32,8 @@ def login(): flash("Invalid username or password", "error") return redirect(url_for("auth.login")) login_user(user, remember=form.remember_me.data) + current_user.last_active = datetime.datetime.utcnow() + db.session.commit() if not user.is_confirmed: return redirect(url_for("auth.not_confirmed")) next_page = request.args.get("next") @@ -122,11 +119,6 @@ def confirm_email(token): else: user.is_confirmed = True db.session.commit() - for task in create_initial_user_tasks(user): - if task is None: - break - db.session.add(task) - db.session.commit() flash("Account confirmed successfully. Thank you!", "success") return redirect(url_for("auth.login")) return redirect(url_for("main.index")) diff --git a/app/decorators.py b/app/decorators.py index df797bd..9fbf1f4 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -16,6 +16,8 @@ def admin_required(func): return func(*args, **kwargs) elif not current_user.is_authenticated: return current_app.login_manager.unauthorized() + elif not current_user.is_confirmed: + return redirect(url_for("auth.not_confirmed")) elif not current_user.is_admin: return current_app.login_manager.unauthorized() return func(*args, **kwargs) diff --git a/app/main/__init__.py b/app/main/__init__.py index 2cb605e..2612509 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -4,6 +4,6 @@ from flask import Blueprint bp = Blueprint('main', __name__) -from app.main import routes +from app.main import routes, demo diff --git a/app/main/demo.py b/app/main/demo.py new file mode 100644 index 0000000..a126fd8 --- /dev/null +++ b/app/main/demo.py @@ -0,0 +1,439 @@ +# -*- coding: utf-8 -*- + +import datetime +import logging +import markdown +import textwrap + +from flask import ( + render_template, + flash, + url_for, + redirect, + request, + session, + abort, +) +from flask_login import current_user + +from app import db +from app.decorators import login_required +from app.models import Annotation, Dataset, Task +from app.main import bp +from app.main.forms import NextForm +from app.main.routes import RUBRIC +from app.utils.datasets import load_data_for_chart, get_demo_true_cps + +LOGGER = logging.getLogger(__name__) + +# textwrap.dedent is used mostly for code formatting. +DEMO_DATA = { + 1: { + "dataset": {"name": "demo_100"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + Welcome to AnnotateChange, an annotation app for change point + detection. + + Our goal with AnnotateChange is to create a dataset of + human-annotated time series to use in the development and + evaluation of change point algorithms. + + We really appreciate that you've agreed to help us with this! + Without your help this project would not be possible. + + In the next few pages, we'll introduce you to the problem of + change point detection. We'll look at a few datasets and see + different types of changes that can occur. + + Thanks again for your help!""" + ) + ) + }, + "annotate": { + "text": RUBRIC + + markdown.markdown( + textwrap.dedent( + """ + Click "Submit" when you have finished marking the change points + or "No change points" when you believe there are none. You can + reset the graph with the "Reset" button.""" + ) + ) + }, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + This first example has **one** change point. Not all datasets + that you'll encounter in this program have exactly one change + point. It is up to you to see whether a time series contains a + change point or not, and if it does, to see if there is more + than one. + + Don't worry if you weren't exactly correct on the first try. + The goal of this introduction is to familiarise yourself with + time series data and with change point detection in + particular.""" + ) + ) + }, + }, + 2: { + "dataset": {"name": "demo_200"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + In the previous example, you've seen a relatively simple + dataset where a *step change* occurred at a certain point in + time. A step change is one of the simplest types of change + points that can occur. + + Click "Continue" to move on to the next example.""" + ) + ) + }, + "annotate": {"text": RUBRIC}, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + The dataset in the previous example shows again a time series + with step changes, but here there are **two** change points. + This is important to keep in mind, as there can be more than + one change point in a dataset.""" + ) + ) + }, + }, + 3: { + "dataset": {"name": "demo_300"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + In the previous examples we've introduced *step changes*. + However, these are not the only types of change points that + can occur, as we'll see in the next example.""" + ) + ) + }, + "annotate": {"text": RUBRIC}, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + This time series shows an example where a change occurs in the + **variance** of the data. At the change point the variance of + the noise changes abruptly from a relatively low noise variance + to a high noise variance. This is another type of change point + that can occur.""" + ) + ) + }, + }, + 4: { + "dataset": {"name": "demo_400"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + So far we have seen two types of change points: step changes + (also known as mean shift) and variance changes.""" + ) + ) + }, + "annotate": {"text": RUBRIC}, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + Remember that it's also possible for there to be *no change + points* in a dataset. It can sometimes be difficult to tell + whether a dataset has change points or not. In that case, it's + important to remember that we are looking for points where the + behaviour of the time series changes *abruptly*.""" + ) + ) + }, + }, + 5: { + "dataset": {"name": "demo_500"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + Change points mark places in the time series where the + behaviour changes *abruptly*. While **outliers** are data + points that do not adhere to the prevailing behaviour of the + time series, they are not generally considered change points + because the behaviour of the time series before and after the + outlier is the same. """ + ) + ) + }, + "annotate": {"text": RUBRIC}, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + Outliers are quite common in real-world time series data, and + not all change point detection methods are robust against these + observations. + + Note that short periods that consist of several consecutive + outlying data points could be considered an abrupt change in + behaviour of the time series. If you see this, use your + intuition to guide you.""" + ) + ) + }, + }, + 6: { + "dataset": {"name": "demo_600"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + So far we've seen *step changes*, *variance changes*, and time + series with *outliers*. Can you think of another type of change + that can occur?""" + ) + ) + }, + "annotate": {"text": RUBRIC}, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + What we see here is a change in *trend*. For these types of + changes it's not always easy to figure out exactly where the + change occurs, so it's harder to get it exactly right. Use + your intuition and keep in mind that it is normal for the + observations to be noisy.""" + ) + ) + }, + }, + 7: { + "dataset": {"name": "demo_700"}, + "learn": { + "text": markdown.markdown( + textwrap.dedent( + """ + It is not uncommon for time series data from the real world to + display **seasonal variation**, for instance because certain + days of the week are more busy than others. Seasonality can + make it harder to find the change points in the dataset (if + there are any at all). Try to follow the pattern of + seasonality, and check whether the pattern changes in one of + the ways we've seen previously.""" + ) + ) + }, + "annotate": {"text": RUBRIC}, + "evaluate": { + "text": markdown.markdown( + textwrap.dedent( + """ + As you can see from this example, changes in seasonality can + occur as well. We expect that these changes are quite rare, + but it's nevertheless good to be aware of them.""" + ) + ) + }, + }, +} + + +def redirect_user(demo_id, phase_id): + last_demo_id = max(DEMO_DATA.keys()) + demo_last_phase_id = 3 + if demo_id == last_demo_id and phase_id == demo_last_phase_id: + # User is introduced. + if current_user.is_introduced: + return redirect(url_for("main.index")) + + current_user.is_introduced = True + db.session.commit() + # TODO: Assign real tasks to the user here. + return redirect(url_for("main.index")) + elif phase_id == demo_last_phase_id: + demo_id += 1 + phase_id = 1 + return redirect( + url_for("main.demo", demo_id=demo_id, phase_id=phase_id) + ) + else: + phase_id += 1 + return redirect( + url_for("main.demo", demo_id=demo_id, phase_id=phase_id) + ) + + +def process_annotations(demo_id): + annotation = request.get_json() + if annotation["identifier"] != demo_id: + LOGGER.error( + "User %s returned a task id in the demo that wasn't the demo id." + % current_user.username + ) + flash( + "An internal error occurred, the administrator has been notified.", + "error", + ) + return redirect(url_for("main.index")) + + if annotation["changepoints"] is None: + retval = [] + else: + retval = [cp["x"] for cp in annotation["changepoints"]] + + # If the user is already introduced, we assume that their demo annotations + # are already in the database, and thus we don't put them back in (because + # we want the original ones). + if current_user.is_introduced: + return retval + + dataset = Dataset.query.filter_by( + name=DEMO_DATA[demo_id]["dataset"]["name"] + ).first() + task = Task.query.filter_by( + annotator_id=current_user.id, dataset_id=dataset.id + ).first() + # this happens if the user returns to the same demo page, but hasn't + # completed the full demo yet. Same as above, not updating because we want + # the originals. + if not task is None: + return retval + + # Create a new task + task = Task(annotator_id=current_user.id, dataset_id=dataset.id) + task.done = False + task.annotated_on = None + db.session.add(task) + db.session.commit() + if annotation["changepoints"] is None: + ann = Annotation(cp_index=None, task_id=task.id) + db.session.add(ann) + db.session.commit() + else: + for cp in annotation["changepoints"]: + ann = Annotation(cp_index=cp["x"], task_id=task.id) + db.session.add(ann) + db.session.commit() + + # mark task as done + task.done = True + task.annotated_on = datetime.datetime.utcnow() + db.session.commit() + + return retval + + +def demo_learn(demo_id, form): + demo_data = DEMO_DATA[demo_id]["learn"] + return render_template( + "demo/learn.html", + title="Introduction – %i" % demo_id, + text=demo_data["text"], + form=form, + ) + + +def demo_annotate(demo_id): + demo_data = DEMO_DATA[demo_id]["annotate"] + dataset = Dataset.query.filter_by( + name=DEMO_DATA[demo_id]["dataset"]["name"] + ).first() + if dataset is None: + LOGGER.error( + "Demo requested unavailable dataset: %s" + % demo_data["dataset"]["name"] + ) + flash( + "An internal error occured. The administrator has been notified. We apologise for the inconvenience, please try again later.", + "error", + ) + return redirect(url_for("main.index")) + chart_data = load_data_for_chart(dataset.name, dataset.md5sum) + return render_template( + "annotate/index.html", + title="Introduction – %i" % demo_id, + data=chart_data, + rubric=demo_data["text"], + identifier=demo_id, + ) + + +def demo_evaluate(demo_id, phase_id, form): + demo_data = DEMO_DATA[demo_id]["evaluate"] + user_changepoints = session.get("user_changepoints", "__UNK__") + if user_changepoints == "__UNK__": + flash( + "The previous step of the demo was not completed successfully. Please try again.", + "error", + ) + return redirect( + url_for("main.demo", demo_id=demo_id, phase_id=phase_id - 1) + ) + dataset = Dataset.query.filter_by( + name=DEMO_DATA[demo_id]["dataset"]["name"] + ).first() + chart_data = load_data_for_chart(dataset.name, dataset.md5sum) + true_changepoints = get_demo_true_cps(dataset.name) + if true_changepoints is None: + flash( + "An internal error occurred, the administrator has been notified. We apologise for the inconvenience, please try again later.", + "error", + ) + return redirect(url_for("main.index")) + annotations_true = [dict(index=x) for x in true_changepoints] + annotations_user = [dict(index=x) for x in user_changepoints] + return render_template( + "demo/evaluate.html", + title="Introduction – %i" % demo_id, + data=chart_data, + annotations_user=annotations_user, + annotations_true=annotations_true, + text=demo_data["text"], + form=form, + ) + + +@bp.route( + "/introduction/", + defaults={"demo_id": 1, "phase_id": 1}, + methods=("GET", "POST"), +) +@bp.route( + "/introduction/<int:demo_id>/", + defaults={"phase_id": 1}, + methods=("GET", "POST"), +) +@bp.route( + "/introduction/<int:demo_id>/<int:phase_id>", methods=("GET", "POST") +) +@login_required +def demo(demo_id, phase_id): + form = NextForm() + + if request.method == "POST": + if form.validate_on_submit(): + return redirect_user(demo_id, phase_id) + else: + user_changepoints = process_annotations(demo_id) + session["user_changepoints"] = user_changepoints + return url_for("main.demo", demo_id=demo_id, phase_id=phase_id + 1) + + if phase_id == 1: + return demo_learn(demo_id, form) + elif phase_id == 2: + return demo_annotate(demo_id) + elif phase_id == 3: + return demo_evaluate(demo_id, phase_id, form) + else: + abort(404) diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 0000000..dcdffd3 --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from flask_wtf import FlaskForm + +from wtforms import SubmitField + +class NextForm(FlaskForm): + submit = SubmitField("Continue") diff --git a/app/main/routes.py b/app/main/routes.py index d249c5c..11de2f9 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import datetime +import logging from flask import render_template, flash, url_for, redirect, request from flask_login import current_user @@ -11,19 +12,13 @@ from app.main import bp from app.models import Annotation, Task from app.utils.datasets import load_data_for_chart +logger = logging.getLogger(__name__) + RUBRIC = """ -<i>Please mark all the points in the time series where an <b>abrupt change</b> -in - the behaviour of the series occurs.</i> -<br> -If there are no such points, please click the <u>no changepoints</u> button. -When you're ready, please click the <u>submit</u> button. -<br> -<br> -<b>Note:</b> You can zoom and pan the graph if needed. +Please mark the points in the time series where an <b>abrupt change</b> in + the behaviour of the series occurs. The goal is to define segments of the time + series that are separated by places where these abrupt changes occur. <br> -<br> -Thank you! """ @@ -35,7 +30,7 @@ def index(): if current_user.is_authenticated: user_id = current_user.id tasks = Task.query.filter_by(annotator_id=user_id).all() - tasks_done = [t for t in tasks if t.done] + tasks_done = [t for t in tasks if t.done and not t.dataset.is_demo] tasks_todo = [t for t in tasks if not t.done] return render_template( "index.html", @@ -48,14 +43,14 @@ def index(): @bp.route("/annotate/<int:task_id>", methods=("GET", "POST")) @login_required -def task(task_id): +def annotate(task_id): if request.method == "POST": # record post time now = datetime.datetime.utcnow() # get the json from the client annotation = request.get_json() - if annotation["task"] != task_id: + if annotation["identifier"] != task_id: flash("Internal error: task id doesn't match.", "error") return redirect(url_for(task_id=task_id)) @@ -100,10 +95,14 @@ def task(task_id): flash("It's not possible to edit annotations at the moment.") return redirect(url_for("main.index")) data = load_data_for_chart(task.dataset.name, task.dataset.md5sum) + if data is None: + flash( + "An internal error occurred loading this dataset, the admin has been notified. Please try again later. We apologise for the inconvenience." + ) return render_template( "annotate/index.html", - title="Annotate %s" % task.dataset.name, - task=task, + title=task.dataset.name.title(), + identifier=task.id, data=data, rubric=RUBRIC, ) diff --git a/app/models.py b/app/models.py index 5016505..f7dd2fa 100644 --- a/app/models.py +++ b/app/models.py @@ -22,8 +22,13 @@ class User(UserMixin, db.Model): db.DateTime(), nullable=False, default=datetime.datetime.utcnow ) is_admin = db.Column(db.Boolean(), default=False) + + # after email is confirmed: is_confirmed = db.Column(db.Boolean(), default=False) + # after all demo tasks completed: + is_introduced = db.Column(db.Boolean(), default=False) + def __repr__(self): return "<User %r>" % self.username @@ -76,6 +81,9 @@ class Dataset(db.Model): ) md5sum = db.Column(db.String(32), unique=True, nullable=False) + # Whether or not dataset is a demo dataset. + is_demo = db.Column(db.Boolean(), default=True) + def __repr__(self): return "<Dataset %r>" % self.name diff --git a/app/static/annotate.css b/app/static/annotate.css index 7dc222f..8079053 100644 --- a/app/static/annotate.css +++ b/app/static/annotate.css @@ -20,5 +20,9 @@ rect { } #rubric { - text-align: center; + text-align: left; + padding-bottom: 20px; + padding-top: 10px; + width: 80%; + font-size: 16px; } diff --git a/app/static/css/demo/evaluate.css b/app/static/css/demo/evaluate.css new file mode 100644 index 0000000..0c7dbc2 --- /dev/null +++ b/app/static/css/demo/evaluate.css @@ -0,0 +1,57 @@ +.graph-wrapper { + width: 90%; + margin: 0 auto; +} + +#graph_user > svg { + width: 100%; + height: 100%; +} + +#graph_true > svg { + width: 100%; + height: 100%; +} + +.line { + fill: none; + stroke: blue; + clip-path: url(#clip); +} + +circle { + clip-path: url(#clip); + fill: blue; +} + +rect { + fill: white; + opacity: 0; +} + +.marked { + fill: #fc8d62; + stroke: #fc8d62; +} + +#next-btn { + text-align: right; + padding-bottom: 20px; +} + +#lesson { + padding-top: 10px; + padding-bottom: 10px; +} + +#lesson p { + font-size: 16px; +} + +.ann-line { + stroke-dasharray: 5; + clip-path: url(#clip); + fill: #fc8d62; + stroke: #fc8d62; + stroke-width: 2px; +} diff --git a/app/static/css/demo/learn.css b/app/static/css/demo/learn.css new file mode 100644 index 0000000..13dccd2 --- /dev/null +++ b/app/static/css/demo/learn.css @@ -0,0 +1,11 @@ +#next-btn { + text-align: right; +} + +#lesson { + padding-top: 10px; +} + +#lesson p { + font-size: 16px; +} diff --git a/app/static/css/global.css b/app/static/css/global.css new file mode 100644 index 0000000..6258057 --- /dev/null +++ b/app/static/css/global.css @@ -0,0 +1,3 @@ +h1, h2, h3 { + margin-bottom: 20px; +} diff --git a/app/static/js/buttons.js b/app/static/js/buttons.js index d26a15d..75adad2 100644 --- a/app/static/js/buttons.js +++ b/app/static/js/buttons.js @@ -8,7 +8,7 @@ function resetOnClick() { updateTable(); } -function noCPOnClick(task_id) { +function noCPOnClick(identifier) { var changepoints = document.getElementsByClassName("changepoint"); // validation if (changepoints.length > 0) { @@ -16,27 +16,27 @@ function noCPOnClick(task_id) { return; } - var obj = { - task: task_id, - changepoints: null - }; + var obj = {} + obj["identifier"] = identifier; + obj["changepoints"] = null; var xhr = new XMLHttpRequest(); - xhr.open("POST", ""); + xhr.open("POST", "", false); xhr.withCredentials = true; xhr.setRequestHeader("Content-Type", "application/json"); - xhr.send(JSON.stringify(obj)); + /* Flask's return to this POST must be a URL, not a template!*/ xhr.onreadystatechange = function() { - if (xhr.readyState == XMLHttpRequest.DONE) { - if (xhr.status === 200) - window.location.href = xhr.responseText; + if (xhr.readyState == XMLHttpRequest.DONE && xhr.status == 200) { + window.location.href = xhr.responseText; + console.log("XHR Success: " + xhr.responseText); } else { - console.log("Error: " + xhr.status); + console.log("XHR Error: " + xhr.status); } - }; + } + xhr.send(JSON.stringify(obj)); } -function submitOnClick(task_id) { +function submitOnClick(identifier) { var changepoints = document.getElementsByClassName("changepoint"); // validation if (changepoints.length === 0) { @@ -45,7 +45,7 @@ function submitOnClick(task_id) { } var obj = {}; - obj["task"] = task_id; + obj["identifier"] = identifier; obj["changepoints"] = []; var i, cp; for (i=0; i<changepoints.length; i++) { @@ -62,14 +62,15 @@ function submitOnClick(task_id) { xhr.open("POST", ""); xhr.withCredentials = true; xhr.setRequestHeader("Content-Type", "application/json"); - xhr.send(JSON.stringify(obj)); + /* Flask's return to this POST must be a URL, not a template!*/ xhr.onreadystatechange = function() { - if (xhr.readyState == XMLHttpRequest.DONE) { - if (xhr.status === 200) - window.location.href = xhr.responseText; + if (xhr.readyState == XMLHttpRequest.DONE && xhr.status == 200) { + window.location.href = xhr.responseText; + console.log("XHR Success: " + xhr.responseText); } else { - console.log("Error: " + xhr.status); + console.log("XHR Error: " + xhr.status); } }; + xhr.send(JSON.stringify(obj)); } diff --git a/app/static/js/makeChart.js b/app/static/js/makeChart.js index 35069b6..fd4a257 100644 --- a/app/static/js/makeChart.js +++ b/app/static/js/makeChart.js @@ -1,4 +1,4 @@ -function makeChart(data) { +function makeChart(selector, data) { var n = 0; data.forEach(function(d) { d.X = n++; @@ -8,7 +8,7 @@ function makeChart(data) { var divWidth = 1000; var divHeight = 480; - var svg = d3.select("#graph") + var svg = d3.select(selector) .on("touchstart", nozoom) .on("touchmove", nozoom) .append("svg") @@ -31,6 +31,7 @@ function makeChart(data) { var xAxis = d3.axisBottom(x); var yAxis = d3.axisLeft(y); + yAxis.ticks(0); var xExtent = d3.extent(data, function(d) { return d.X; }); var xRange = xExtent[1] - xExtent[0]; @@ -49,13 +50,13 @@ function makeChart(data) { svg.append("defs").append("clipPath") .attr("id", "clip") .append("rect") - .attr("width", width - 30) + .attr("width", width - 18) .attr("height", height) - .attr("transform", "translate(" + 30 + ",0)"); + .attr("transform", "translate(" + 18 + ",0)"); svg.append("g") .attr("class", "axis axis--y") - .attr("transform", "translate(" + 30 + ",0)") + .attr("transform", "translate(" + 18 + ",0)") .call(yAxis); svg.append("g") @@ -127,3 +128,138 @@ function makeChart(data) { d3.event.preventDefault(); } } + +function makeChartAnnotated(selector, data, annotations) { + var n = 0; + data.forEach(function(d) { + d.X = n++; + d.Y = d.value; + }); + + var divWidth = 1000; + var divHeight = 480; + + var svg = d3.select(selector) + .on("touchstart", nozoom) + .on("touchmove", nozoom) + .append("svg") + .attr("width", divWidth) + .attr("height", divHeight) + .attr("viewBox", "0 0 " + divWidth + " " + divHeight); + + var margin = {top: 20, right: 20, bottom: 50, left: 50}; + var width = +svg.attr("width") - margin.left - margin.right; + var height = +svg.attr("height") - margin.top - margin.bottom; + + var zoom = d3.zoom() + .scaleExtent([1, 50]) + .translateExtent([[0, 0], [width, height]]) + .extent([[0, 0], [width, height]]) + .on("zoom", zoomed); + + var x = d3.scaleLinear().range([0, width]); + var x2 = d3.scaleLinear().range([0, width]); + var y = d3.scaleLinear().range([height, 0]); + + var xAxis = d3.axisBottom(x); + var yAxis = d3.axisLeft(y); + yAxis.ticks(0); + + var xExtent = d3.extent(data, function(d) { return d.X; }); + var xRange = xExtent[1] - xExtent[0]; + var xDomainMin = xExtent[0] - xRange * 0.02; + var xDomainMax = xExtent[1] + xRange * 0.02; + + var yExtent = d3.extent(data, function(d) { return d.Y; }); + var yRange = yExtent[1] - yExtent[0]; + var yDomainMin = yExtent[0] - yRange * 0.05; + var yDomainMax = yExtent[1] + yRange * 0.05; + + x.domain([xDomainMin, xDomainMax]); + y.domain([yDomainMin, yDomainMax]); + x2.domain(x.domain()); + + svg.append("defs").append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width - 18) + .attr("height", height) + .attr("transform", "translate(" + 18 + ",0)"); + + svg.append("g") + .attr("class", "axis axis--y") + .attr("transform", "translate(" + 18 + ",0)") + .call(yAxis); + + svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(xAxis); + + svg.append("text") + .attr("text-anchor", "middle") + .attr("class", "axis-label") + .attr("transform", "translate(" + (width - 20) + "," + (height + 50) + ")") + .text("Time"); + + var line = d3.line() + .x(function(d) { return x(d.X); }) + .y(function(d) { return y(d.Y); }); + + var g = svg.append("g") + .call(zoom); + + g.append("rect") + .attr("width", width) + .attr("height", height); + + var view = g.append("g") + .attr("class", "view"); + + view.append("path") + .datum(data) + .attr("class", "line") + .attr("d", line); + + var points = view.selectAll("circle") + .data(data) + .enter().append("circle") + .attr("cx", function(d) { return x(d.X); }) + .attr("cy", function(d) { return y(d.Y); }) + .attr("data_X", function(d) { return d.X; }) + .attr("data_Y", function(d) { return d.Y; }) + .attr("r", 5); + + function zoomed() { + t = d3.event.transform; + x.domain(t.rescaleX(x2).domain()); + svg.select(".line").attr("d", line); + points.data(data) + .attr("cx", function(d) { return x(d.X); }) + .attr("cy", function(d) { return y(d.Y); }); + svg.select(".axis--x").call(xAxis); + } + + function nozoom() { + d3.event.preventDefault(); + } + + annotations.forEach(function(a) { + for (i=0; i<points._groups[0].length; i++) { + p = points._groups[0][i]; + if (p.getAttribute("data_X") == a.index) { + var elem = d3.select(p); + elem.classed("marked", "true"); + view.append("line") + .attr("cp_idx", a.index) + .attr("y1", y(yDomainMax)) + .attr("y2", y(yDomainMin)) + .attr("x1", x(a.index)) + .attr("x2", x(a.index)) + .attr("class", "ann-line"); + break; + } + } + }); +} + diff --git a/app/static/view_annotation.css b/app/static/view_annotation.css index 4b4f338..11bf3c8 100644 --- a/app/static/view_annotation.css +++ b/app/static/view_annotation.css @@ -11,7 +11,7 @@ .ann-line { fill: none; - stroke-dasharray: 3,3; + stroke-dasharray: 5; clip-path: url(#clip); } @@ -64,4 +64,3 @@ rect { fill: #b3b3b3; stroke: #b3b3b3; } - diff --git a/app/templates/_partials/modals.html b/app/templates/_partials/modals.html new file mode 100644 index 0000000..1ffd85f --- /dev/null +++ b/app/templates/_partials/modals.html @@ -0,0 +1,21 @@ +{% macro modal(id, title, message) %} +<!-- Modal --> +<div class="modal fade" id="{{ id }}Modal" tabindex="-1" role="dialog" aria-labelledby="{{ id }}ModalTitle" aria-hidden="true"> + <div class="modal-dialog modal-dialog-centered" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="{{ id }}LongTitle">{{ title }}</h5> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + {{ message }} + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> + </div> + </div> + </div> +</div> +{% endmacro %} diff --git a/app/templates/admin/annotations_by_dataset.html b/app/templates/admin/annotations_by_dataset.html index 5ad98b6..eb7a7e8 100644 --- a/app/templates/admin/annotations_by_dataset.html +++ b/app/templates/admin/annotations_by_dataset.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block styles %} -{{super()}} +{{ super() }} <link rel="stylesheet" href="{{url_for('static', filename='view_annotation.css')}}">{% endblock %} {% block app_content %} diff --git a/app/templates/admin/manage_datasets.html b/app/templates/admin/manage_datasets.html index f14fe81..f68259f 100644 --- a/app/templates/admin/manage_datasets.html +++ b/app/templates/admin/manage_datasets.html @@ -42,18 +42,20 @@ <thead class="thead-dark"> <th scope="col">ID</th> <th scope="col">Name</th> - <th scope="col">Assigned Tasks</th> - <th scope="col">Completed Tasks</th> - <th scope="col">Percentage</th> + <th scope="col">Demo</th> + <th scope="col">Assigned Tasks</th> + <th scope="col">Completed Tasks</th> + <th scope="col">Percentage</th> </thead> {% for entry in overview %} <tr> - <th scope="row">{{ entry['id'] }}</th> - <td>{{ entry['name'] }}</td> - <td>{{ entry['assigned'] }}</td> - <td>{{ entry['completed'] }}</td> - <td>{{ entry['percentage'] }}</td> - </tr> + <th scope="row">{{ entry['id'] }}</th> + <td>{{ entry['name'] }}</td> + <td>{% if entry['demo'] %}Yes{% else %}No{% endif %}</td> + <td>{{ entry['assigned'] }}</td> + <td>{{ entry['completed'] }}</td> + <td>{{ entry['percentage'] }}</td> + </tr> {% endfor %} </tr> </table> diff --git a/app/templates/admin/manage_users.html b/app/templates/admin/manage_users.html index 5c44a48..8788958 100644 --- a/app/templates/admin/manage_users.html +++ b/app/templates/admin/manage_users.html @@ -1,70 +1,72 @@ {% extends "base.html" %} {% block app_content %} -<h1>Manage Users</h1> + <h1>Manage Users</h1> -<div class="col-lg-3"> + <div class="col-lg-3"> <div class="row"> - <form class="form" action="" method="POST"> - {{ form.hidden_tag() }} - {{ form.user }} - {{ form.delete(hidden='true', id='form-submit') }} - <!-- Button trigger modal --> - <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#deleteModal"> - Delete - </button> - </form> + <form class="form" action="" method="POST"> + {{ form.hidden_tag() }} + {{ form.user }} + {{ form.delete(hidden='true', id='form-submit') }} + <!-- Button trigger modal --> + <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#deleteModal"> + Delete + </button> + </form> </div> -</div> + </div> -<!-- Modal --> -<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true"> - <div class="modal-dialog modal-dialog-centered" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <h5 class="modal-title" id="deleteModalLabel">Delete Dataset</h5> - </div> - <div class="modal-body"> - You are about to delete the user <span id="user-name"></span> <b>and</b> all associated tasks and annotations. Are you sure? - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button> - <button type="button" class="btn btn-success success" id="modal-confirm">Confirm</button> + <!-- Modal --> + <div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-dialog-centered" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="deleteModalLabel">Delete Dataset</h5> + </div> + <div class="modal-body"> + You are about to delete the user <span id="user-name"></span> <b>and</b> all associated tasks and annotations. Are you sure? + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-success success" id="modal-confirm">Confirm</button> + </div> </div> </div> </div> -</div> -<br> -<h1>User Overview</h1> -<article class="overview"> - <table class="table table-striped"> - <thead class="thead-dark"> - <th scope="col">ID</th> - <th scope="col">Username</th> - <th scope="col">Email</th> - <th scope="col">Confirmed</th> - <th scope="col">Last Active</th> - <th scope="col">Admin</th> - </thead> - {% for user in users %} - <tr> - <th scope="row">{{ user.id }}</th> - <td>{{ user.username }}</td> - <td>{{ user.email }}</td> - <td>{% if user.is_confirmed %}Yes{% else %}No{% endif %}</td> - <td>{{ user.last_active }}</td> - <td>{% if user.is_admin %}Yes{% else %}{% endif %}</td> - </tr> - {% endfor %} - </tr> - </table> -</article> + <br> + <h1>User Overview</h1> + <article class="overview"> + <table class="table table-striped"> + <thead class="thead-dark"> + <th scope="col">ID</th> + <th scope="col">Username</th> + <th scope="col">Email</th> + <th scope="col">Confirmed?</th> + <th scope="col">Introduced?</th> + <th scope="col">Last Active (UTC)</th> + <th scope="col">Admin?</th> + </thead> + {% for user in users %} + <tr> + <th scope="row">{{ user.id }}</th> + <td>{{ user.username }}</td> + <td>{{ user.email }}</td> + <td>{% if user.is_confirmed %}Yes{% else %}No{% endif %}</td> + <td>{% if user.is_introduced %}Yes{% else %}No{% endif %}</td> + <td>{{ user.last_active }}</td> + <td>{% if user.is_admin %}Yes{% else %}{% endif %}</td> + </tr> + {% endfor %} + </tr> + </table> + </article> -<script> -var conf = document.getElementById("modal-confirm"); -conf.onclick = function() { - document.getElementById("form-submit").click(); -}; -</script> + <script> + var conf = document.getElementById("modal-confirm"); + conf.onclick = function() { + document.getElementById("form-submit").click(); + }; + </script> {% endblock %} diff --git a/app/templates/annotate/index.html b/app/templates/annotate/index.html index 106ef2a..f4172b6 100644 --- a/app/templates/annotate/index.html +++ b/app/templates/annotate/index.html @@ -1,12 +1,13 @@ {% extends "base.html" %} +{% import "_partials/modals.html" as modals %} {% block styles %} -{{super()}} +{{ super() }} <link rel="stylesheet" href="{{url_for('static', filename='annotate.css')}}"> {% endblock %} {% block app_content %} -<h1>{{ task.dataset.name }}</h1> +<h1>{{ title }}</h1> <div id="rubric" class="row"> <div class="col-md-12"> @@ -18,54 +19,25 @@ <div id="wrap-buttons" class="row"> <div class="col-md-6 text-left"> - <button class="btn btn-primary float-md-left" type="button" id="btn-reset">Reset</button> + <button class="btn btn-primary float-md-left" type="button" + id="btn-reset">Reset</button> </div> <div class="col-md-6 text-right"> - <button class="btn btn-warning float-md-right" type="button" id="btn-none">No Changepoints</button> - <button class="btn btn-success float-md-right" id="btn-submit" type="button">Submit</button> + <button class="btn btn-warning float-md-right" type="button" + id="btn-none">No change points</button> + <button class="btn btn-success float-md-right" id="btn-submit" + type="button">Submit</button> </div> </div> <br> -<!-- Modal --> -<div class="modal fade" id="submitNoCPModal" tabindex="-1" role="dialog" aria-labelledby="submitNoCPModalTitle" aria-hidden="true"> - <div class="modal-dialog modal-dialog-centered" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <h5 class="modal-title" id="submitNoCPModalLongTitle">No Changepoints Selected</h5> - <button type="button" class="close" data-dismiss="modal" aria-label="Close"> - <span aria-hidden="true">×</span> - </button> - </div> - <div class="modal-body"> - Please use the "No Changepoints" button when you think there are no changepoints in the time series. - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> - </div> - </div> - </div> -</div> +{{ modals.modal("submitNoCP", "No Change Points Selected", "Please use the +\"No Change Points\" button when you think there are no change points in the +time series.") }} -<!-- Modal --> -<div class="modal fade" id="NoCPYesCPModal" tabindex="-1" role="dialog" aria-labelledby="NoCPYesCPModalTitle" aria-hidden="true"> - <div class="modal-dialog modal-dialog-centered" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <h5 class="modal-title" id="NoCPYesCPModalLongTitle">Changepoints Selected</h5> - <button type="button" class="close" data-dismiss="modal" aria-label="Close"> - <span aria-hidden="true">×</span> - </button> - </div> - <div class="modal-body"> - There are selected changepoints, please click the Reset button before clicking the "No Changepoints" button. - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> - </div> - </div> - </div> -</div> +{{ modals.modal("NoCPYesCP", "Change Points Selected", "There are selected +change points, please click the Reset button before clicking the \"No change +points\" button.") }} <h3>Selected Changepoints</h3> <div id="changepoint-table"> @@ -79,7 +51,7 @@ <script src="{{ url_for('static', filename='js/makeChart.js') }}"></script> <script src="{{ url_for('static', filename='js/updateTable.js') }}"></script> <script src="{{ url_for('static', filename='js/buttons.js') }}"></script> -<script>makeChart({{ data.chart_data | safe }});</script> +<script>makeChart("#graph", {{ data.chart_data | safe }});</script> <script> // reset button var reset = document.getElementById("btn-reset"); @@ -87,12 +59,12 @@ reset.onclick = resetOnClick; // no changepoint button var nocp = document.getElementById("btn-none"); nocp.onclick = function() { - noCPOnClick({{ task.id }}); + noCPOnClick({{ identifier }}); }; // submit button var submit = document.getElementById("btn-submit"); submit.onclick = function() { - submitOnClick({{ task.id }}); + submitOnClick({{ identifier }}); }; </script> {% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 8bc31dc..f11760a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -2,12 +2,13 @@ {% import "bootstrap/utils.html" as util %} {% block styles %} -{{ super() }} + {{ super() }} + <link rel="stylesheet" href="{{ url_for('static', filename='css/global.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='sticky_footer.css') }}"> {% endblock %} {% block title %} - {% if title %}{{ title }} -- AnnotateChange{% else %}Welcome to AnnotateChange{% endif %} + {% if title %}{{ title }} · AnnotateChange{% else %}Welcome to AnnotateChange{% endif %} {% endblock %} {% block navbar %} @@ -28,13 +29,13 @@ </ul> <ul class="nav navbar-nav navbar-right"> {% if current_user.is_anonymous %} - <li><a href="{{ url_for('auth.login') }}">Login</a></li> - <li><a href="{{ url_for('auth.register') }}">Register</a></li> + <li><a href="{{ url_for('auth.login') }}">Login</a></li> + <li><a href="{{ url_for('auth.register') }}">Register</a></li> {% else %} - {% if current_user.is_admin %} - <li><a href="/admin" style="color: red;">Admin Panel</a></li> - {% endif %} - <li><a href="{{ url_for('auth.logout') }}">Logout</a></li> + {% if current_user.is_admin %} + <li><a href="{{ url_for('admin.index') }}" style="color: red;">Admin Panel</a></li> + {% endif %} + <li><a href="{{ url_for('auth.logout') }}">Logout</a></li> {% endif %} </ul> </div> @@ -44,19 +45,13 @@ {% block content %} <main class="container"> - - {{ util.flashed_messages(dismissible=True) }} - - {% block app_content %} - {% endblock %} + {{ util.flashed_messages(dismissible=True) }} + {% block app_content %} + {% endblock %} </main> <footer class="fixed-bottom"> - <div class="container"> - <span class="text-muted">This is AnnotateChange version {{ - config.APP_VERSION }}. For questions or comments, - please contact <a - href="mailto:gvandenburg@turing.ac.uk">Gertjan van den - Burg</a>.</span> - </div> + <div class="container"> + <span class="text-muted">This is AnnotateChange version {{ config.APP_VERSION }}. For questions or comments, please <a href="mailto:annotatechange@gmail.com">send us an email</a>.</span> + </div> </footer> {% endblock %} diff --git a/app/templates/demo/evaluate.html b/app/templates/demo/evaluate.html new file mode 100644 index 0000000..b148f66 --- /dev/null +++ b/app/templates/demo/evaluate.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{ super() }} +<link rel="stylesheet" href="{{url_for('static', filename='css/demo/evaluate.css')}}"> +{% endblock %} + +{% block app_content %} +<h1>{{ title }}</h1> + +<div id="lesson" class="row"> + <div class="col-md-8"> + {{ text | safe }} + </div> +</div> + +<div class="graph-wrapper"> + <div class="row"> + <p><b>Your annotation:</b></p> + </div> + <div id="graph_user"></div> + <div class="row"> + <p><b>Ground truth:</b></p> + </div> + <div id="graph_true"></div> +</div> + +<div class="row"> + <div id="next-btn" class="col-md-10"> + {{ wtf.quick_form(form, button_map={'submit': 'primary'}) }} + </div> +</div> + +<script src="//d3js.org/d3.v5.min.js"></script> +<script src="{{ url_for('static', filename='js/makeChart.js') }}"></script> +<script>makeChartAnnotated( + "#graph_user", + {{ data.chart_data | safe }}, + {{ annotations_user | safe }} +); +</script> +<script>makeChartAnnotated( + "#graph_true", + {{ data.chart_data | safe }}, + {{ annotations_true | safe }} +); +</script> +{% endblock %} diff --git a/app/templates/demo/learn.html b/app/templates/demo/learn.html new file mode 100644 index 0000000..8d3d6c9 --- /dev/null +++ b/app/templates/demo/learn.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{ super() }} +<link rel="stylesheet" href="{{url_for('static', filename='css/demo/learn.css')}}"> +{% endblock %} + +{% block app_content %} +<h1>{{ title }}</h1> + +<div id="lesson" class="row"> + <div class="col-md-6"> + {{ text | safe }} + </div> +</div> + +<div class="row"> + <div id="next-btn" class="col-md-6"> + {{ wtf.quick_form(form, button_map={'submit': 'primary'}) }} + </div> +</div> +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 76edbcd..930bfd3 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,69 +1,98 @@ {% extends "base.html" %} {% block styles %} -{{ super() }} -<link rel="stylesheet" href="{{url_for('static', filename='user_index.css')}}"> + {{ super() }} + <link rel="stylesheet" href="{{url_for('static', filename='user_index.css')}}"> {% endblock %} {% block app_content %} -{% if current_user.is_authenticated %} - <h1>Hi, {{ current_user.username }}!</h1> - {% if tasks_todo %} - <h2>Annotations to be done</h2> - <br> - <p> - Below are the datasets that we've asked you to annotate, thank you - very much for your help! - </p> - <br> - <div class="tasks-todo"> - <table class="table table-striped"> - <thead class="thead-dark"> - <th scope="col">Name</th> - </thead> - {% for task in tasks_todo %} - <tr> - <td><a href="/annotate/{{ task.id }}">{{ task.dataset.name }}</a></td> - </tr> - {% endfor %} - </table> - </div> - {% elif tasks_todo|length == 0 and tasks_done|length > 0 %} - <div id="done"> - <img src="/static/done.png"> - <span>No more annotations to do! Thank you so much for your - help, <b>you rock!</b></span> - </div> - {% else %} - <span>There are no datasets for you to annotate at the moment, please - check back again later. Thank you!</span> - {% endif %} - {% if tasks_done %} - <h2>Completed Annotations</h2> - <div class="tasks-done"> - <table class="table table-striped"> - <thead> - <th scope="col">Name</th> - <th scope="col">Completed On</th> - </thead> - {% for task in tasks_done %} - <tr> - <td>{{ task.dataset.name }}</td> - <td>{{ task.annotated_on }}</td> - </tr> - {% endfor %} - </table> - </div> - {% endif %} -{% else %} - <article> - <div> - Welcome to <i>AnnotateChange</i> a tool for annotating time - series data for changepoint analysis. - <br> - <br> - Please log in or register to get started. - </div> - </article> -{% endif %} + {% if current_user.is_authenticated %} + <h1>Hi, {{ current_user.username }}!</h1> + {% endif %} + <p> + Welcome to <i>AnnotateChange</i> a tool for annotating time series data + for changepoint analysis. + </p> + {% if not current_user.is_authenticated %} + <br> + <p> + Please <a href="{{ url_for('auth.login') }}">log in</a> or + <a href="{{ url_for('auth.register') }}">register</a> to get started. + </p> + {% endif %} + {% if current_user.is_authenticated %} + <h3>Introduction</h3> + {% if not current_user.is_introduced %} + <a href="{{ url_for('main.demo') }}">Click here to start the introduction to AnnotateChange.</a> + {% if tasks_todo|length == 0 and tasks_done|length == 0 %} + <br> + <br> + <p> + When you have finished the introduction, the datasets to annotate + will appear here. + </p> + {% endif %} + {% else %} + <p> + Thank you for completing the introduction. If you want to revisit + it, you can do so by <a href="{{ url_for('main.demo') }}">clicking here</a>. + </p> + {% if tasks_todo|length == 0 and tasks_done|length == 0 %} + <br> + <br> + <p> + There are currently no datasets for you to annotate. Please check back + again later. + </p> + {% endif %} + {% endif %} + {% if tasks_todo %} + <h3>Datasets to Annotate</h3> + <p> + Below are the datasets that we would like you to annotate, thank you + very much for your help! + </p> + <div class="tasks-todo"> + <table class="table table-striped"> + <thead class="thead-dark"> + <th scope="col">Name</th> + </thead> + {% for task in tasks_todo %} + <tr> + <td> + <a href="{{ url_for('main.annotate', task_id=task.id) }}"> + {{ task.dataset.name | title }} + </a> + </td> + </tr> + {% endfor %} + </table> + </div> + {% elif tasks_todo|length == 0 and tasks_done|length > 0 %} + <div id="done"> + <img src="{{ url_for('static', filename='done.png') }}"> + <span> + No more annotations to do! Thank you so much for your help, + <b>you rock!</b> + </span> + </div> + {% endif %} + {% if tasks_done %} + <h3>Completed Annotations</h3> + <div class="tasks-done"> + <table class="table table-striped"> + <thead> + <th scope="col">Name</th> + <th scope="col">Completed On</th> + </thead> + {% for task in tasks_done %} + <tr> + <td>{{ task.dataset.name | title }}</td> + <td>{{ task.annotated_on }}</td> + </tr> + {% endfor %} + </table> + </div> + {% endif %} + {% endif %} {% endblock %} diff --git a/app/utils/datasets.py b/app/utils/datasets.py index 74785cb..1fef85f 100644 --- a/app/utils/datasets.py +++ b/app/utils/datasets.py @@ -41,7 +41,7 @@ import re from flask import current_app -logger = logging.getLogger(__file__) +LOGGER = logging.getLogger(__file__) def validate_dataset(filename): @@ -95,6 +95,32 @@ def get_name_from_dataset(filename): return data["name"] +def dataset_is_demo(filename): + with open(filename, "rb") as fid: + data = json.load(fid) + return "demo" in data + + +def get_demo_true_cps(name): + dataset_dir = os.path.join( + current_app.instance_path, current_app.config["DATASET_DIR"] + ) + target_filename = os.path.join(dataset_dir, name + ".json") + if not os.path.exists(target_filename): + LOGGER.error("Dataset with name '%s' can't be found!" % name) + return None + with open(target_filename, "rb") as fid: + data = json.load(fid) + if not "demo" in data: + LOGGER.error("Asked for 'demo' key in non-demo dataset '%s'" % name) + return None + if not "true_CPs" in data["demo"]: + LOGGER.error( + "Expected field'true_cps' field missing for dataset '%s'" % name + ) + return data["demo"]["true_CPs"] + + def md5sum(filename): """ Compute the MD5 hash for a given filename """ blocksize = 65536 @@ -112,8 +138,11 @@ def load_data_for_chart(name, known_md5): current_app.instance_path, current_app.config["DATASET_DIR"] ) target_filename = os.path.join(dataset_dir, name + ".json") + if not os.path.exists(target_filename): + LOGGER.error("Dataset with name '%s' can't be found!" % name) + return None if not md5sum(target_filename) == known_md5: - logger.error( + LOGGER.error( """ MD5 checksum failed for dataset with name: %s. Found: %s. diff --git a/migrations/versions/43c24fc4dd24_changes_for_demo.py b/migrations/versions/43c24fc4dd24_changes_for_demo.py new file mode 100644 index 0000000..8bd3086 --- /dev/null +++ b/migrations/versions/43c24fc4dd24_changes_for_demo.py @@ -0,0 +1,30 @@ +"""changes for demo + +Revision ID: 43c24fc4dd24 +Revises: 386d5c61a29b +Create Date: 2019-05-30 13:20:54.504462 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '43c24fc4dd24' +down_revision = '386d5c61a29b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('dataset', sa.Column('is_demo', sa.Boolean(), nullable=True)) + op.add_column('user', sa.Column('is_introduced', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'is_introduced') + op.drop_column('dataset', 'is_demo') + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index 007a034..2ce758e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -248,6 +248,17 @@ MarkupSafe = ">=0.9.2" [[package]] category = "main" +description = "Python implementation of Markdown." +name = "markdown" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +version = "3.1.1" + +[package.dependencies] +setuptools = ">=36" + +[[package]] +category = "main" description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" optional = false @@ -380,7 +391,7 @@ python-versions = "*" version = "2.2.1" [metadata] -content-hash = "17221732a240b9e6bcae8517bb21e2032b8749962fe680f3dfba434f8aea1770" +content-hash = "0055b739d3afaaf6ffa9ca7720fc6869f5cb4d0b338beb28aa3d8c2b6f1e4bd9" python-versions = "^3.7" [metadata.hashes] @@ -408,6 +419,7 @@ idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8 itsdangerous = ["321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"] jinja2 = ["74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", "f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"] mako = ["4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae"] +markdown = ["2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a", "56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"] markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] more-itertools = ["0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", "590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"] pluggy = ["19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", "84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"] diff --git a/pyproject.toml b/pyproject.toml index a9d0d9d..8544bc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ email_validator = "^1.0" gunicorn = "^19.9" pymysql = "^0.9.3" cryptography = "^2.6" +markdown = "^3.1" [tool.poetry.dev-dependencies] pytest = "^3.0" |
