diff --git a/.gitignore b/.gitignore index 53254ec..d81c451 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,10 @@ venv.bak/ # Rope project settings .ropeproject +# Sublime project settings +*.sublime-project +*.sublime-workspace + # mkdocs documentation /site diff --git a/ennead/app.py b/ennead/app.py index 2c17a27..3fafd12 100644 --- a/ennead/app.py +++ b/ennead/app.py @@ -11,6 +11,7 @@ from ennead.views.admin import adm_task_list_page, task_edit_page, task_edit, task_delete from ennead.views.system import render_markdown_endpoint from ennead.views.file import upload_file, uploaded_file, files_page +from ennead.views.dialogue import thread_page, post_to_thread from ennead.models.base import database from ennead.models.file import File @@ -63,6 +64,10 @@ def create_app(config_path: Optional[str] = None) -> Flask: app.add_url_rule('/login', 'login', login, methods=['POST']) app.add_url_rule('/logout', 'logout', logout) + app.add_url_rule('/thread//', 'thread', thread_page) + app.add_url_rule('/thread//', + 'post_to_thread', post_to_thread, methods=['POST']) + app.add_url_rule('/adm/tasks', 'adm_task_list_page', adm_task_list_page) app.add_url_rule('/adm/tasks/', 'task_edit_page', task_edit_page) app.add_url_rule('/adm/tasks', 'task_edit', task_edit, methods=['POST']) diff --git a/ennead/models/file.py b/ennead/models/file.py index bf7a51b..c97d264 100644 --- a/ennead/models/file.py +++ b/ennead/models/file.py @@ -46,7 +46,7 @@ def from_data(cls, directory: str, name: str, data: bytes, user: User) -> 'File' file_entry.save() dir_path = os.path.join(os.path.realpath(directory), file_entry.token) - os.mkdir(dir_path) + os.makedirs(dir_path) full_path = os.path.normpath(os.path.join(dir_path, name)) if not full_path.startswith(dir_path + os.sep): raise FileCreationError(f"{name} doesn't seems like correct file name") diff --git a/ennead/models/task.py b/ennead/models/task.py index 61e198a..32b0f51 100644 --- a/ennead/models/task.py +++ b/ennead/models/task.py @@ -27,6 +27,10 @@ class TaskSet(BaseModel): tasks: List['Task'] threads: List['Thread'] + @property + def ordered_tasks(self) -> List["Task"]: + return sorted(self.tasks, key=lambda task: task.order_num) + class Task(BaseModel): """One task for student @@ -34,14 +38,18 @@ class Task(BaseModel): Attributes: name: human-readable name of `Task` description: `Task` description in Markdown + solution: correct `Task` solution and comments about work review process base_score: basic maximal score for `Task` task_set: set this `Task` belongs to + order_num: order of this `Task` in a `TaskSet` threads: list of `Thread`s about this `Task` """ name: str = CharField() description: str = TextField() + solution: str = TextField() base_score: int = IntegerField() + order_num: int = IntegerField() task_set: TaskSet = ForeignKeyField(TaskSet, backref='tasks') threads: List['Thread'] @@ -51,3 +59,9 @@ def html_description(self): """`Task` description in HTML""" return render_markdown(self.description) + + @property + def html_solution(self): + """`Task` solution in HTML""" + + return render_markdown(self.solution) diff --git a/ennead/models/thread.py b/ennead/models/thread.py index b811b96..7775572 100644 --- a/ennead/models/thread.py +++ b/ennead/models/thread.py @@ -3,12 +3,14 @@ import datetime from typing import List -from peewee import DateTimeField, IntegerField, TextField, ForeignKeyField +from peewee import DateTimeField, DecimalField, TextField, BooleanField, ForeignKeyField from ennead.models.user import User from ennead.models.task import Task from ennead.models.base import BaseModel +from ennead.utils import render_markdown + class Thread(BaseModel): """Student-with-teachers chat about `Task` @@ -21,11 +23,18 @@ class Thread(BaseModel): """ task: Task = ForeignKeyField(Task, backref='threads') - score: int = IntegerField() + score: float = DecimalField(default=0) student: User = ForeignKeyField(User, backref='threads') posts: List['Post'] + def ordered_posts(self, show_hidden=False): + posts = self.posts + if not show_hidden: + posts = filter(lambda post: not post.hide_from_student, posts) + posts = sorted(posts, key=lambda post: post.date) + return posts + class Post(BaseModel): """One post in `Thread` with `User` about `Task` @@ -35,9 +44,17 @@ class Post(BaseModel): date: date this `Post` was posted author: `User` who wrote this post thread: `Thread` this task belongs to + hide_from_student: marks `Post` as auxiliary (for teacher's use only) """ text: str = TextField() date: datetime.datetime = DateTimeField() author: User = ForeignKeyField(User, backref='+') # '+' means 'no backref' + hide_from_student: bool = BooleanField(default=False) thread: Thread = ForeignKeyField(Thread, backref='posts') + + @property + def html_text(self): + """`Post` text in HTML""" + + return render_markdown(self.text) diff --git a/ennead/models/user.py b/ennead/models/user.py index d0d69b5..1637f97 100644 --- a/ennead/models/user.py +++ b/ennead/models/user.py @@ -75,6 +75,12 @@ def check_password(self, password: str) -> bool: self.password_sha512 ) + @property + def is_student(self) -> bool: + """Check is user a student""" + + return self.group == UserGroup.student + @property def is_teacher(self) -> bool: """Check is user a teacher""" @@ -82,7 +88,7 @@ def is_teacher(self) -> bool: return self.group == UserGroup.teacher @property - def score(self) -> int: + def score(self) -> float: """Get `User`s score in current task set""" return sum( diff --git a/ennead/static/editor.js b/ennead/static/editor.js index 5f21b76..3fa2f06 100644 --- a/ennead/static/editor.js +++ b/ennead/static/editor.js @@ -1,87 +1,101 @@ window.addEventListener('load', function() { - var editors = document.getElementsByClassName('markdown-editor'); - for (let editor of editors) { - var sourceLink = editor.getElementsByClassName('markdown-editor-source-link')[0]; - var previewLink = editor.getElementsByClassName('markdown-editor-preview-link')[0]; - var editorTextarea = editor.getElementsByClassName('markdown-editor-source')[0]; - var preview = editor.getElementsByClassName('markdown-editor-preview')[0]; - var fileUploader = editor.getElementsByClassName('file-uploader')[0]; - if (fileUploader === undefined) { - continue; - } - var fileUploaderInput = fileUploader.getElementsByClassName('file-uploader-input')[0]; - var fileUploaderLink = fileUploader.getElementsByClassName('file-uploader-link')[0]; - if ( - sourceLink === undefined || - previewLink === undefined || - editorTextarea === undefined || - preview === undefined || - fileUploaderInput === undefined || - fileUploaderLink === undefined - ) { - continue; - } + var editors = document.getElementsByClassName('markdown-editor'); + for (let editor of editors) { + var sourceLink = editor.getElementsByClassName('markdown-editor-source-link')[0]; + var previewLink = editor.getElementsByClassName('markdown-editor-preview-link')[0]; + var editorTextarea = editor.getElementsByClassName('markdown-editor-source')[0]; + var preview = editor.getElementsByClassName('markdown-editor-preview')[0]; + var fileUploader = editor.getElementsByClassName('file-uploader')[0]; + if (fileUploader === undefined) { + continue; + } + var fileUploaderInput = fileUploader.getElementsByClassName('file-uploader-input')[0]; + var fileUploaderLink = fileUploader.getElementsByClassName('file-uploader-link')[0]; + if ( + sourceLink === undefined || + previewLink === undefined || + editorTextarea === undefined || + preview === undefined || + fileUploaderInput === undefined || + fileUploaderLink === undefined + ) { + continue; + } - sourceLink.onclick = function() { - if (editorTextarea.classList.contains('d-none')) { - editorTextarea.classList.remove('d-none'); - preview.classList.add('d-none'); - sourceLink.classList.add('active'); - previewLink.classList.remove('active'); - fileUploader.classList.remove('d-none'); - } - } + sourceLink.onclick = function() { + if (editorTextarea.classList.contains('d-none')) { + editorTextarea.classList.remove('d-none'); + preview.classList.add('d-none'); + sourceLink.classList.add('active'); + previewLink.classList.remove('active'); + fileUploader.classList.remove('d-none'); + } + } - previewLink.onclick = function() { - if (preview.classList.contains('d-none')) { - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function() { - if (xhr.readyState == 4 && xhr.status == 200) { - preview.innerHTML = xhr.responseText; - preview.querySelectorAll('pre code').forEach(function(block) { - hljs.highlightBlock(block); - }); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, preview]); - preview.classList.remove('d-none'); - editorTextarea.classList.add('d-none'); - previewLink.classList.add('active'); - sourceLink.classList.remove('active'); - fileUploader.classList.add('d-none'); - } + previewLink.onclick = function() { + if (preview.classList.contains('d-none')) { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState == 4 && xhr.status == 200) { + preview.innerHTML = xhr.responseText; + preview.querySelectorAll('pre code').forEach(function(block) { + hljs.highlightBlock(block); + }); + MathJax.Hub.Queue(["Typeset", MathJax.Hub, preview]); + preview.classList.remove('d-none'); + editorTextarea.classList.add('d-none'); + previewLink.classList.add('active'); + sourceLink.classList.remove('active'); + fileUploader.classList.add('d-none'); + } + } + xhr.open('POST', '/md', true); + xhr.send(editorTextarea.value); + } } - xhr.open('POST', '/md', true); - xhr.send(editorTextarea.value); - } - } - fileUploaderLink.onclick = function() { - fileUploaderInput.click(); - } + fileUploaderLink.onclick = function() { + fileUploaderInput.click(); + } - fileUploaderInput.onchange = function() { - var file = fileUploaderInput.files[0]; - if (file === undefined) { - return; - } + fileUploaderInput.onchange = function() { + var file = fileUploaderInput.files[0]; + if (file === undefined) { + return; + } - var formData = new FormData(); - formData.append('file', file); + var formData = new FormData(); + formData.append('file', file); - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function() { - if (xhr.readyState == 4 && xhr.status == 200) { - var fileLink = '[' + file.name + '](' + xhr.responseText + ')'; - if (file.type.startsWith('image/')) { - fileLink = '!' + fileLink; - } - if (editorTextarea.value.slice(-1) != '\n') { - editorTextarea.value += '\n'; - } - editorTextarea.value += fileLink; + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState == 4 && xhr.status == 200) { + var fileLink = '[' + file.name + '](' + xhr.responseText + ')'; + if (file.type.startsWith('image/')) { + fileLink = '!' + fileLink; + } + if (editorTextarea.value.slice(-1) != '\n') { + editorTextarea.value += '\n'; + } + editorTextarea.value += fileLink; + } + }; + xhr.open('POST', '/upload', true); + xhr.send(formData); + } + } +}); + +window.addEventListener("load", function() { + let forms = document.getElementsByTagName('form'); + for (let form of forms) { + let textareas = form.getElementsByTagName('textarea'); + for (let textarea of textareas) { + textarea.addEventListener('keydown', function(event) { + if (event.ctrlKey && event.keyCode == 13) { // Ctrl-Enter pressed + form.submit(); + } + }); } - }; - xhr.open('POST', '/upload', true); - xhr.send(formData); } - } }); diff --git a/ennead/static/style.css b/ennead/static/style.css index aeaf912..7c3aec2 100644 --- a/ennead/static/style.css +++ b/ennead/static/style.css @@ -1,45 +1,83 @@ /* Fix for Bootstrap 4 broken navbar baseline */ .nav-link, .navbar-text, .nav-item > .btn { - margin-top: 0.125rem; + margin-top: 0.125rem; } /* Fix for Bootstrap 4 broken navbar buttons & text margin */ .nav-item > .btn, .navbar-text{ - margin-left: 0.1rem; - margin-right: 0.1rem; + margin-left: 0.1rem; + margin-right: 0.1rem; } .btn.icon::before { - margin-right: 0.4em; - position: relative; - top: 0.05em; + margin-right: 0.4em; + position: relative; + top: 0.05em; } .markdown-editor-source { - width: 100%; - border: 0; - resize: none; - height: 15em; + width: 100%; + border: 0; + resize: none; + height: 15em; } .markdown-editor-preview { - min-height: 15em; + min-height: 15em; } .markdown-editor-tab-content { - border-radius: 0 0 0.25rem 0.25rem; - border-top: 0; + border-radius: 0 0 0.25rem 0.25rem; + border-top: 0; } .nav-tabs .nav-item.markdown-editor-nav-item { - margin-bottom: -2px; + margin-bottom: -2px; } .markdown-image { - display: block; - margin-top: 1em; - margin-bottom: 1em; - margin-left: auto; - margin-right: auto; - max-width: 70%; + display: block; + margin-top: 1em; + margin-bottom: 1em; + margin-left: auto; + margin-right: auto; + max-width: 70%; +} + +.post { + border: 1px solid black; + margin: 10px 0; + padding: 10px; +} +.post-teacher { + background-color: #ccf; +} +.post-teacher-hidden { + background-color: #aaa; +} +.bg-secondary.text-white .text-muted, .bg-dark.text-white .text-muted { + color: white!important; +} + +.post-student { + text-align: left; + background-color: white; +} + +.post-header { + font-size: 0.75em; + color: #555; +} + +.post-date { + display: inline-block; +} + +.post-author { + display: inline-block; + margin-right: 10px; +} + +#post-textarea { + width: 100%; } diff --git a/ennead/templates/base.html b/ennead/templates/base.html index 0fe678d..43b200f 100644 --- a/ennead/templates/base.html +++ b/ennead/templates/base.html @@ -1,87 +1,87 @@ - - - {% block title %}{% endblock %}{% if self.title() %} — {% endif %}Ennead + + + {% block title %}{% endblock %}{% if self.title() %} — {% endif %}Ennead - + - - + + - - - + + + - {% block head %} - {% endblock %} + {% block head %} + {% endblock %} - {% from 'editor.html' import markdown_editor %} - - - + {% from 'editor.html' import markdown_editor %} + + + -
- {% if splash_text %} -
-
- {{splash_text}} -
-
- {% endif %} - {% block body %} - {% endblock %} -
+
+ {% if splash_text %} +
+
+ {{splash_text}} +
+
+ {% endif %} + {% block body %} + {% endblock %} +
- - - - - - - + + + + + + + diff --git a/ennead/templates/dialogue.html b/ennead/templates/dialogue.html new file mode 100644 index 0000000..67b5561 --- /dev/null +++ b/ennead/templates/dialogue.html @@ -0,0 +1,81 @@ +{% extends 'base.html' %} +{% set task=thread.task %} +{% set student=thread.student %} +{% block title %}{{task.order_num}}. {{ task.name }}{% endblock %} + +{% block body %} +
+
+
+
+

{{ task.order_num }}. {{ task.name }}

+ Балл: {{ thread.score }} из {{ task.base_score }} +
+
+
{{ task.html_description|safe }}
+
+
+ {% if g.user.is_teacher %} +
+
Критерии проверки
+
+
{{ task.html_solution|safe }}
+
+
+ {% endif %} +
+
+ {% for post in posts %} + {% if post.author.is_teacher %} + {% set offset_class = 'offset-2' %} + {% if post.hide_from_student %} + {% set post_class = 'post-teacher-hidden bg-secondary text-white' %} + {% else %} + {% set post_class = 'post-teacher' %} + {% endif %} + {%else%} + {% set offset_class = 'offset-1' %} + {% set post_class = 'post-student' %} + {%endif%} +
+
+
+
+

+ + {% if g.user.is_teacher %} + + {% endif %} + + +

+
{{ post.html_text|safe }}
+
+
+
+
+ {% endfor %} +
+
+
+
+ {{ markdown_editor(text=msg_value, name='text', id='post-textarea') }} + {% if g.user.is_teacher %} +
+ + +
+
+ +
+ +
+
+ {% endif %} + +
+
+
+{% endblock %} diff --git a/ennead/templates/editor.html b/ennead/templates/editor.html index d02f929..901090e 100644 --- a/ennead/templates/editor.html +++ b/ennead/templates/editor.html @@ -1,34 +1,34 @@ {% macro markdown_editor(text='', name='', id='') %} {% if not id %} - {% set id = name %} + {% set id = name %} {% endif %} {% endmacro %} diff --git a/ennead/templates/files.html b/ennead/templates/files.html index 4fe8d7b..4cde8ec 100644 --- a/ennead/templates/files.html +++ b/ennead/templates/files.html @@ -2,23 +2,23 @@ {% block title %}Файлы{% endblock %} {% block body %} -

Файлы

- - - - - - - - - - {% for file in files %} - - - - - - {% endfor %} - -
ПользовательФайлДата
{{file.user.first_name}} {{file.user.surname}} ({{file.user.username}}){{file.name}}{{file.uploaded_at}}
+

Файлы

+ + + + + + + + + + {% for file in files %} + + + + + + {% endfor %} + +
ПользовательФайлДата
{{file.user.first_name}} {{file.user.surname}} ({{file.user.username}}){{file.name}}{{file.uploaded_at}}
{% endblock %} diff --git a/ennead/templates/login.html b/ennead/templates/login.html index 758dce5..6086329 100644 --- a/ennead/templates/login.html +++ b/ennead/templates/login.html @@ -3,19 +3,19 @@ {% block title %}Вход{% endblock %} {% block body %} -
-
-
-
- - -
-
- - -
- -
-
-
+
+
+
+
+ + +
+
+ + +
+ +
+
+
{% endblock %} diff --git a/ennead/templates/register.html b/ennead/templates/register.html index aca86c3..cacaf33 100644 --- a/ennead/templates/register.html +++ b/ennead/templates/register.html @@ -3,35 +3,35 @@ {% block title %}Ennead / Регистрация{% endblock %} {% block body %} -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
-
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
{% endblock %} diff --git a/ennead/utils/__init__.py b/ennead/utils/__init__.py index b1528fb..af35bfb 100644 --- a/ennead/utils/__init__.py +++ b/ennead/utils/__init__.py @@ -9,7 +9,11 @@ from ennead.utils.markdown import render_markdown -__all__ = ['require_logged_in', 'require_not_logged_in', 'require_teacher', 'render_markdown'] +__all__ = [ + 'require_logged_in', 'require_not_logged_in', + 'require_teacher', 'require_student', + 'render_markdown', +] def require_logged_in(func: Callable) -> Callable: @@ -47,3 +51,16 @@ def wrapped(*args: Any, **kwargs: Any) -> Response: abort(403) return wrapped + + +def require_student(func: Callable) -> Callable: + """Make endpoint require logged in student""" + + # pylint: disable=inconsistent-return-statements + @wraps(func) + def wrapped(*args: Any, **kwargs: Any) -> Response: + if g.user and g.user.is_student: + return func(*args, **kwargs) + abort(403) + + return wrapped diff --git a/ennead/views/dialogue.py b/ennead/views/dialogue.py new file mode 100644 index 0000000..2eb346e --- /dev/null +++ b/ennead/views/dialogue.py @@ -0,0 +1,80 @@ +"""Views for dialogues""" + +import datetime +from flask import g + +from flask import render_template, request, redirect, url_for +from werkzeug.wrappers import Response + +from ennead.utils import require_logged_in +from ennead.models.user import User +from ennead.models.task import Task +from ennead.models.thread import Thread, Post + + +def has_access_to_thread(student_id): + return g.user.is_teacher or (g.user.is_student and (g.user.id == student_id)) + + +def get_thread(task_id, student_id): + task = Task.get_by_id(task_id) + student = User.get_by_id(student_id) + thread, _ = Thread.get_or_create(task=task, student=student) + return thread + + +def correct_message(text): + return (len(text) != 0) + + +@require_logged_in +def change_thread_score(thread: Thread, score: float) -> None: + """Change thread score (if necessary) and notify student in a dialog""" + + if thread.score != score: + score_change_msg = f"Балл изменен. Текущий балл: {score}." + Post.create(text=score_change_msg, date=datetime.datetime.now(), + author=g.user, thread=thread, + hide_from_student=False) + thread.update(score=score).execute() + + +@require_logged_in +def thread_page(task_id: int, student_id: int) -> Response: + """GET /thread/{task}/{student}: show specified thread""" + + if not has_access_to_thread(student_id): + return redirect(url_for('index')) + thread = get_thread(task_id, student_id) + posts = thread.ordered_posts(show_hidden=g.user.is_teacher) + return render_template('dialogue.html', thread=thread, posts=posts, + msg_value='', score_value=thread.score) + + +@require_logged_in +def post_to_thread(task_id: int, student_id: int) -> Response: + """POST /thread/{task}/{student}: send a message to a specified thread""" + + if not has_access_to_thread(student_id): + return redirect(url_for('index')) + thread = get_thread(task_id, student_id) + + text = request.form['text'].strip() + hide_from_student = g.user.is_teacher and request.form.get('hide_from_student', False) + + if correct_message(text): + Post.create(text=text, date=datetime.datetime.now(), + author=g.user, thread=thread, + hide_from_student=hide_from_student) + else: + # Right now, unnecessary because only empty message can be incorrect + posts = thread.ordered_posts(show_hidden=g.user.is_teacher) + return render_template('dialogue.html', thread=thread, posts=posts, + msg_value=request.form.get('text'), + score_value=request.form.get('score')) + + if g.user.is_teacher: + score = float(request.form.get('score')) + change_thread_score(thread, score) + + return redirect(url_for('thread', task_id=task_id, student_id=student_id)) diff --git a/populate_db.py b/populate_db.py new file mode 100644 index 0000000..21bc3fc --- /dev/null +++ b/populate_db.py @@ -0,0 +1,60 @@ +import datetime +from ennead.config import Config + +from ennead.models.base import database +from ennead.models.user import User, UserGroup +from ennead.models.task import TaskSet, Task +from ennead.models.thread import Thread, Post +from ennead.models.file import File + +config_path = 'ennead.json' +if config_path: + config = Config.from_filename(config_path) +else: + config = Config() + +database.initialize(config.DB_CLASS(config.DB_NAME, **config.DB_PARAMS)) +database.create_tables([User, Task, TaskSet, Thread, Post, File]) + +student = User( + username = 'test', + email = 'test@test.com', + registered_at = datetime.datetime.now(), + first_name = 'Иван', + surname = 'Петров', + patronym = 'Иванович', + group = UserGroup.student) +student.set_password('password') +student.save() + +teacher = User( + username = 'prep', + email = 'prep@test.com', + registered_at = datetime.datetime.now(), + first_name = 'Препод', + surname = 'Злой', + patronym = '', + group = UserGroup.teacher) +teacher.set_password('password') +teacher.save() + + +prev_task_set = TaskSet.create(name='Старая заочка', active=True) +task_set = TaskSet.create(name='Текущая заочка', active=True) + +task_1_1 = Task.create(order_num=1, name='Очень старая задача #1', description='Когда трава была зеленее', solution="Как-нибудь проверим", base_score=1, task_set=prev_task_set) +task_1_2 = Task.create(order_num=2, name='Очень старая задача #2', description='И задачи были забористей', solution="Как-нибудь проверим", base_score=1, task_set=prev_task_set) + +task_2_1 = Task.create(order_num=1, name='Задача первая', description='Начнём с простого: $$2+2=?$$', solution="Ноль баллов обычно.\nПолный балл за $$2+2=4$$", base_score=1, task_set=task_set) +task_2_2 = Task.create(order_num=3, name='Задача последняя', description='Хардкор', solution="Как-нибудь проверим", base_score=42, task_set=task_set) +task_2_3 = Task.create(order_num=2, name='Задача два', description='Посложнее', solution="Как-нибудь проверим", base_score=5, task_set=task_set) + +thread_1 = Thread.create(student=student, task=task_2_1) +thread_2 = Thread.create(student=student, task=task_2_2) + +post_1_1 = Post.create(thread=thread_1, text='Первый коммент', date = datetime.datetime.now(), author=student) +post_1_2 = Post.create(thread=thread_1, text='Так себе "решение". Пока 0 баллов', date = datetime.datetime.now(), author=teacher) +post_1_3 = Post.create(thread=thread_1, text='Ну ладно, \\(2+2=3\\)', date = datetime.datetime.now(), author=student) +post_1_3 = Post.create(thread=thread_1, text='Ой, $$2+2=4$$', date = datetime.datetime.now(), author=student) +post_1_4 = Post.create(thread=thread_1, text='Это вот серьёзно сейчас было?', hide_from_student=True, date = datetime.datetime.now(), author=teacher) +post_1_5 = Post.create(thread=thread_1, text='Ок, угадал. 1 балл', date = datetime.datetime.now(), author=teacher)