"""Common functions for use with routes."""
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from typing import DefaultDict
import pytz
from timApp.auth.get_user_rights_for_item import get_user_rights_for_item
from timApp.document.areainfo import AreaStart, AreaEnd
from timApp.document.docentry import DocEntry
from timApp.document.docparagraph import DocParagraph
from timApp.document.docsettings import DocSettings
from timApp.document.document import Document, dereference_pars
from timApp.document.editing.globalparid import GlobalParId
from timApp.document.hide_names import force_hide_names
from timApp.document.macroinfo import get_user_specific_macros
from timApp.document.par_basic_data import ParBasicData
from timApp.document.prepared_par import PreparedPar
from timApp.document.usercontext import UserContext
from timApp.document.viewcontext import ViewContext
from timApp.markdown.autocounters import TimSandboxedEnvironment
from timApp.markdown.markdownconverter import expand_macros
from timApp.note.notes import get_notes, UserNoteAndUser
from timApp.plugin.plugin import Plugin
from timApp.plugin.pluginControl import pluginify
from timApp.readmark.readings import (
get_common_readings,
get_read_expiry_condition,
has_anything_read,
)
from timApp.readmark.readmarkcollection import ReadMarkCollection
from timApp.readmark.readparagraph import ReadParagraph
from timApp.user.user import User, has_no_higher_right
from timApp.util.flask.responsehelper import flash_if_visible
from timApp.util.timtiming import taketime
from timApp.util.utils import getdatetime, get_boolean
[docs]@dataclass
class PostProcessResult:
texts: list[PreparedPar]
js_paths: list[str]
css_paths: list[str]
should_mark_all_read: bool
plugins: list[Plugin]
has_plugin_errors: bool
# TODO: post_process_pars is called twice in one save??? Or even 4 times, 2 after editor is closed??
[docs]def post_process_pars(
doc: Document,
pars: list[DocParagraph],
user_ctx: UserContext,
view_ctx: ViewContext,
sanitize: bool = True,
do_lazy: bool = False,
load_plugin_states: bool = True,
filter_return: GlobalParId | None = None,
) -> PostProcessResult:
taketime("start pluginify")
pars_deref = dereference_pars(pars, context_doc=doc, view_ctx=view_ctx)
if filter_return:
pars_deref = [
p
for p in pars_deref
if p.get_doc_id() == filter_return.doc_id
and p.get_id() == filter_return.par_id
]
presult = pluginify(
doc,
pars_deref,
user_ctx,
view_ctx,
sanitize=sanitize,
do_lazy=do_lazy,
load_states=load_plugin_states,
)
final_pars = presult.pars
taketime("end pluginify")
should_mark_all_read = False
settings = doc.get_settings()
macroinfo = settings.get_macroinfo(view_ctx, user_ctx)
user_macros = get_user_specific_macros(user_ctx)
macros = macroinfo.get_macros()
delimiter = macroinfo.get_macro_delimiter()
doc_nomacros = settings.nomacros()
# Process user-specific macros.
env = macroinfo.jinja_env
for (
p
) in (
final_pars
): # update only user specific, because others are done in a cache pahes
if (
not p.is_plugin() and not p.is_setting()
): # TODO: Think if plugins still needs to expand macros?
# p.insert_rnds(0)
no_macros = DocParagraph.is_no_macros(p.get_attrs(), doc_nomacros)
if not no_macros:
ppar = p.prepare(view_ctx)
ppar.output = expand_macros(
ppar.output,
user_macros,
settings,
env=env,
ignore_errors=True,
)
# taketime("macros done")
if view_ctx.preview:
# Skip readings and notes
return PostProcessResult(
texts=process_areas(settings, final_pars, macros, delimiter, env, view_ctx),
js_paths=presult.js_paths,
css_paths=presult.css_paths,
should_mark_all_read=should_mark_all_read,
plugins=presult.all_plugins,
has_plugin_errors=presult.has_errors,
)
if settings.show_authors():
hide_authors = view_ctx.hide_names_requested
authors = doc.get_changelog(-1).get_authorinfo(pars)
if hide_authors:
for ainfo in authors.values():
for a in ainfo.authors:
if isinstance(a, User):
a.hide_name = True
for p in final_pars:
ppar = p.prepare(view_ctx)
ppar.authorinfo = authors.get(ppar.id)
# There can be several references of the same paragraph in the document, which is why we need a dict of lists
pars_dict: DefaultDict[tuple[str, int], list[PreparedPar]] = defaultdict(list)
docinfo = doc.get_docinfo()
curr_user = user_ctx.logged_user
if not curr_user.has_edit_access(docinfo):
for p in final_pars:
if p.is_question():
d = p.prepare(view_ctx)
d.output = " "
d.html_class = "hidden"
if p.is_setting():
d = p.prepare(view_ctx)
d.output = " "
else:
ids = doc.get_par_ids()
first_par = doc.get_paragraph(ids[0]) if ids else None
last_par = doc.get_paragraph(ids[-1]) if ids else None
show_settings_yaml = (
last_par.is_setting() and first_par.is_setting()
if last_par and last_par
else True
)
if not show_settings_yaml:
for p in final_pars:
if p.is_setting():
d = p.prepare(view_ctx)
d.output = " "
for p in final_pars:
d = p.prepare(view_ctx)
if p.original and not p.original.is_translation():
target = d.target
key = target.id, target.doc_id
pars_dict[key].append(d)
key = d.data.id, d.data.doc_id
pars_dict[key].append(d)
for p in final_pars:
d = p.prepare(view_ctx)
d.status = ReadMarkCollection()
d.notes = []
# taketime("pars done")
group = curr_user.get_personal_group().id
if curr_user.logged_in and not settings.hide_readmarks():
# taketime("readings begin")
# TODO: UserContext should support multiple users like in group login.
usergroup_ids = [user_ctx.logged_user.get_personal_group().id]
# If we're in exam mode and we're visiting the page for the first time, mark everything read
if should_auto_read(
doc, usergroup_ids, user_ctx.logged_user, view_ctx.for_cache
):
should_mark_all_read = True
readings = []
else:
readings = get_common_readings(
usergroup_ids, doc, get_read_expiry_condition(settings.read_expiry())
)
taketime("readings end")
for r in readings: # type: ReadParagraph
key = (r.par_id, r.doc_id)
pars = pars_dict.get(key)
if pars:
for p in pars:
if r.par_hash == p.data.hash or (
p.target and r.par_hash == p.target.hash
):
p.status.add(r)
else:
p.status.add(r, modified=True)
taketime("read mixed")
notes = get_notes(group, doc)
# db.session.close()
# taketime("notes picked")
should_hide_names = view_ctx.hide_names_requested or force_hide_names(
curr_user, docinfo
)
comment_docs = {docinfo.id: docinfo}
teacher_access_cache = {}
for n, u in notes:
key = (n.par_id, n.doc_id)
pars = pars_dict.get(key)
if pars:
if n.doc_id not in comment_docs:
comment_docs[n.doc_id] = DocEntry.find_by_id(n.doc_id)
has_teacher = teacher_access_cache.get(n.doc_id)
if has_teacher is None:
has_teacher = bool(curr_user.has_teacher_access(comment_docs[n.doc_id]))
teacher_access_cache[n.doc_id] = has_teacher
editable = n.usergroup_id == group or has_teacher
private = n.access == "justme"
for p in pars:
if p.notes is None:
p.notes = []
if should_hide_names and u.id != curr_user.id:
u.hide_name = True
p.notes.append(
UserNoteAndUser(user=u, note=n, editable=editable, private=private)
)
# taketime("notes mixed")
return PostProcessResult(
texts=process_areas(settings, final_pars, macros, delimiter, env, view_ctx),
js_paths=presult.js_paths,
css_paths=presult.css_paths,
should_mark_all_read=should_mark_all_read,
plugins=presult.all_plugins,
has_plugin_errors=presult.has_errors,
)
[docs]@dataclass
class Area:
name: str
attrs: dict
visible: bool | None = None
# TODO: It would be better to return a tree-like structure of the document instead of a flat list.
[docs]def process_areas(
settings: DocSettings,
pars: list[DocParagraph],
macros,
delimiter,
env: TimSandboxedEnvironment,
view_ctx: ViewContext,
use_md: bool = False,
cache: bool = True,
) -> list[PreparedPar]:
# If we're only dealing with a single paragraph (happens e.g. when posting a comment),
# we don't want to include area start/end markers in the final output
# because the HTML would be broken.
is_single = len(pars) == 1
now = pytz.utc.localize(datetime.now())
min_time = pytz.utc.localize(datetime.min)
max_time = pytz.utc.localize(datetime.max)
# Currently open areas. Should be empty after the loop unless there are missing area_ends.
current_areas: list[Area] = []
# All non-reference areas that we've seen. Only insert here, never remove.
encountered_areas: dict[str, Area] = {}
new_pars: list[PreparedPar] = []
fix = "Fix this to get rid of this warning."
for p in pars:
html_par = p.prepare(view_ctx, use_md, cache)
cur_area = None
area_start = p.get_attr("area")
area_end = p.get_attr("area_end")
if area_start is not None:
cur_area = Area(area_start, p.get_attrs())
current_areas.append(cur_area)
if not p.ref_chain:
if area_start in encountered_areas:
flash_if_visible(
f"Area {area_start} appears more than once in this document. {fix}",
view_ctx,
)
encountered_areas[area_start] = cur_area
if area_end is not None:
if area_start is not None:
flash_if_visible(
f"The paragraph {p.get_id()} has both area and area_end. {fix}",
view_ctx,
)
if current_areas:
# Insert a closing paragraph for the current area.
# We do this regardless of whether the area_end name matches because it's reasonable and we
# cannot guess what the user is trying to do.
if not is_single:
html_par.areainfo = AreaEnd(area_end)
new_pars.append(html_par)
try:
latest_area = current_areas.pop()
except IndexError:
flash_if_visible(
f'area_end found for "{area_end}" without corresponding start. {fix}',
view_ctx,
)
else:
if latest_area.name != area_end:
flash_if_visible(
f'area_end found for "{area_end}" without corresponding start. {fix}',
view_ctx,
)
if area_start is not None or area_end is not None:
if area_start is not None:
# Insert an opening paragraph for new areas
if not is_single:
collapse = cur_area.attrs.get("collapse")
html_par.areainfo = AreaStart(
area_start,
collapse not in ("false", "") if collapse is not None else None,
)
new_pars.append(html_par)
vis = cur_area.visible
if vis is None:
vis = cur_area.attrs.get("visible")
if vis is None:
vis = True
elif isinstance(vis, str):
if vis.find(delimiter) >= 0:
vis = expand_macros(
vis, macros, settings, env=env, ignore_errors=True
)
vis = get_boolean(vis, True)
cur_area.visible = vis
if vis:
st = cur_area.attrs.get("starttime")
et = cur_area.attrs.get("endtime")
if st or et:
starttime = getdatetime(st, default_val=min_time)
endtime = getdatetime(et, default_val=max_time)
if not starttime <= now < endtime:
alttext = cur_area.attrs.get("alttext")
if alttext is None:
alttext = "This area can only be viewed from <STARTTIME> to <ENDTIME>"
alttext = alttext.replace(
"<STARTTIME>", str(starttime)
).replace("<ENDTIME>", str(endtime))
new_pars.append(
DocParagraph.create(
doc=Document(html_par.doc_id),
par_id=html_par.id,
md=alttext,
).prepare(view_ctx, use_md, cache)
)
else:
# Hide output of the area paragraph if it's there (e.g. collapse title)
html_par.areainfo.is_collapsed = None
html_par.output = ""
else:
# Just a normal paragraph
access = True
vis = p.get_attr(
"visible"
) # check if there is visible attribute in par itself
if vis is None:
pass
else:
if str(vis).find(delimiter) >= 0:
vis = expand_macros(
vis, macros, settings, env=env, ignore_errors=True
)
vis = get_boolean(vis, True)
if not vis: # TODO: if in preview, put this always True
access = False # TODO: this should be added as some kind of small par that is visible in edit-mode
# Timed paragraph
if access: # par itself is visible, is it in some area that is not visible
for a in current_areas:
assert a.visible is not None
if not a.visible:
access = False
break
if access: # is there time limitation in area where par is included
st = a.attrs.get("starttime")
et = a.attrs.get("endtime")
if st or et:
starttime = getdatetime(st, default_val=min_time)
endtime = getdatetime(et, default_val=max_time)
access &= starttime <= now < endtime
if access:
new_pars.append(html_par)
# Complete unbalanced areas.
if current_areas and not is_single:
flash_if_visible(
f"{len(current_areas)} areas are missing area_end: {current_areas}",
view_ctx,
)
for _ in current_areas:
new_pars.append(
PreparedPar(
data=ParBasicData(attrs={}, doc_id=-1, hash="", id="", md=""),
output="",
from_preamble=None,
target=None,
areainfo=AreaEnd(name=""),
html_class="",
)
)
return new_pars
[docs]def should_auto_read(
doc: Document, usergroup_ids: list[int], user: User, for_cache: bool = False
) -> bool:
return user.get_prefs().auto_mark_all_read or (
has_no_higher_right(
doc.get_settings().exam_mode(),
get_user_rights_for_item(doc.docinfo, user, allow_duration=for_cache),
)
and not has_anything_read(usergroup_ids, doc)
)