from __future__ import annotations
from itertools import accumulate
from typing import List, Iterable, Generator, Tuple, Optional, TYPE_CHECKING
from sqlalchemy.orm import joinedload
from timApp.document.docparagraph import DocParagraph
from timApp.document.document import Document
from timApp.document.specialnames import (
TEMPLATE_FOLDER_NAME,
PREAMBLE_FOLDER_NAME,
DEFAULT_PREAMBLE_DOC,
)
from timApp.item.item import Item
from timApp.notification.notification import Notification
from timApp.timdb.sqa import db
from timApp.util.utils import get_current_time, partition
if TYPE_CHECKING:
from timApp.document.translation.translation import Translation
[docs]class DocInfo(Item):
"""A base class for DocEntry and Translation."""
@property
def path(self) -> str:
raise NotImplementedError
@property
def path_without_lang(self) -> str:
raise NotImplementedError
@property
def id(self) -> int:
raise NotImplementedError
@property
def is_original_translation(self) -> bool:
"""Returns whether this object is the document from which other translated documents were created."""
return self.id == self.src_docid
@property
def src_docid(self) -> int:
"""Returns the source document id in case of a translation or the document id itself otherwise."""
return self.id
@property
def src_doc(self) -> DocInfo:
"""Returns the source document in case of a translation or the document itself otherwise."""
if self.is_original_translation:
return self
from timApp.document.docentry import DocEntry
return DocEntry.find_by_id(self.src_docid)
@property
def aliases(self):
from timApp.document.docentry import DocEntry
return DocEntry.find_all_by_id(self.src_docid)
@property
def document(self) -> Document:
"""Returns the corresponding Document object."""
if getattr(self, "_doc", None) is None:
self._doc = Document(self.id)
self._doc.docinfo = self
return self._doc
@property
def document_as_current_user(self) -> Document:
if getattr(self, "_doc", None) is None:
from timApp.auth.sessioninfo import get_current_user_group
self._doc = Document(self.id, modifier_group_id=get_current_user_group())
self._doc.docinfo = self
return self._doc
@property
def last_modified(self):
return self.block.modified if self.block else None
@property
def translations(self) -> list[Translation]:
"""Returns the translations of the document. NOTE: The list *includes* the document itself."""
raise NotImplementedError
@property
def lang_id(self) -> str | None:
raise NotImplementedError
[docs] def update_last_modified(self) -> None:
self.block.modified = get_current_time()
[docs] def get_preamble_docs(self) -> list[DocInfo]:
"""Gets the list of preamble documents for this document.
The first document in the list is the nearest root.
"""
if getattr(self, "_preamble_docs", None) is None:
preamble_setting = self.document.get_own_settings().get(
"preamble", DEFAULT_PREAMBLE_DOC
)
self._preamble_docs = (
self._get_preamble_docs_impl(preamble_setting)
if isinstance(preamble_setting, str)
else []
)
return self._preamble_docs
[docs] def get_preamble_pars_with_class(self, class_names: list[str]):
"""
Get all preamble pars with any of the given classes.
:param class_names: Class names.
:return: Filtered pars from the preamble document.
"""
return get_pars_with_class_from_docs(self.get_preamble_docs(), class_names)
[docs] def get_preamble_pars(self) -> Generator[DocParagraph, None, None]:
return get_non_settings_pars_from_docs(self.get_preamble_docs())
def _get_preamble_docs_impl(self, preamble_setting: str) -> list[DocInfo]:
preamble_names = preamble_setting.split(",")
path_parts = self.path_without_lang.split("/")
# An absolute path begins with "/" and "/preambles/" appears in it.
# If the conditions are met, then proceed as in the relative preamble.
def absolute_path(variable: str) -> bool:
variable = variable.strip()
return variable.startswith("/") and f"/{PREAMBLE_FOLDER_NAME}/" in variable
# These two lists are mutually exclusive to avoid if statements.
absolute_path_parts, relative_path_parts = partition(
absolute_path, preamble_names
)
paths = list(
f"{p}{TEMPLATE_FOLDER_NAME}/{PREAMBLE_FOLDER_NAME}/{preamble_name.strip()}"
for p in accumulate(part + "/" for part in path_parts[:-1])
for preamble_name in relative_path_parts
)
for preamble_name in absolute_path_parts:
preamble_path_parts = preamble_name.split("/")[1:-2]
preamble_name = preamble_name.split("/")[-1]
paths.extend(
f"{p}{PREAMBLE_FOLDER_NAME}/{preamble_name.strip()}"
for p in accumulate(part + "/" for part in preamble_path_parts)
)
# Remove duplicates and then self-reference
paths = list(dict.fromkeys(paths))
if self.path_without_lang in paths:
paths.remove(self.path_without_lang)
if not paths:
return []
path_index_map = {path: i for i, path in enumerate(paths)}
# Templates don't have preambles.
if any(p == TEMPLATE_FOLDER_NAME for p in path_parts):
return []
from timApp.document.docentry import DocEntry
from timApp.document.translation.translation import Translation
result = (
db.session.query(DocEntry, Translation)
.filter(DocEntry.name.in_(paths))
.outerjoin(
Translation,
(Translation.src_docid == DocEntry.id)
& (Translation.lang_id == self.lang_id),
)
.all()
) # type: List[Tuple[DocEntry, Optional[Translation]]]
result.sort(key=lambda x: path_index_map[x[0].path])
preamble_docs = []
for de, tr in result:
d = tr or de # preamble either has the corresponding translation or not
preamble_docs.append(d)
d.document.ref_doc_cache = self.document.ref_doc_cache
return preamble_docs
[docs] def get_changelog_with_names(self, length=None):
if not length:
length = getattr(self, "changelog_length", 100)
return self.document.get_changelog(length)
[docs] def get_notifications(self, condition) -> list[Notification]:
items = set()
for a in self.aliases:
items.update(a.parents_to_root())
items.add(self)
from timApp.user.user import User
q = Notification.query.options(
joinedload(Notification.user).joinedload(User.groups)
).filter(Notification.block_id.in_([f.id for f in items]))
q = q.filter(condition)
return q.all()
[docs] def has_translation(self, lang_id):
for t in self.translations:
if t.lang_id == lang_id:
return True
return False
[docs] def add_alias(self, new_name, is_public):
from timApp.document.docentry import DocEntry
d = DocEntry(id=self.src_docid, name=new_name, public=is_public)
db.session.add(d)
[docs] def to_json(self, **kwargs):
return {
**super().to_json(**kwargs),
"isFolder": False,
**(
{
"versions": self.get_changelog_with_names(),
"fulltext": self.document.export_markdown(),
}
if getattr(self, "serialize_content", False)
else {}
),
}
[docs]def get_non_settings_pars_from_docs(
docs: Iterable[DocInfo],
) -> Generator[DocParagraph, None, None]:
for d in docs:
for p in d.document:
if not p.is_setting() or p.is_area():
yield p
[docs]def get_pars_with_class_from_docs(
docs: Iterable[DocInfo], class_names: list[str]
) -> Generator[DocParagraph, None, None]:
"""
Loads all non-settings pars that have the given class.
:param docs: Document.
:param class_names: Class name list.
:return: Pars that have any of the filtering class names.
"""
for p in get_non_settings_pars_from_docs(docs):
classes = p.classes
if classes:
for class_name in class_names:
if class_name in classes:
yield p
[docs]def move_document(d: DocInfo, destination):
aliases = d.aliases
for a in aliases[1:]:
db.session.delete(a)
first_alias = aliases[0]
first_alias.name = find_free_name(destination, first_alias)
first_alias.public = True
[docs]def find_free_name(destination, item: Item):
from timApp.document.docentry import DocEntry
from timApp.folder.folder import Folder
short_name = item.short_name
attempt = 0
while True:
trash_path = f"{destination.path}/{short_name}"
if not Folder.find_by_path(trash_path) and not DocEntry.find_by_path(
trash_path
):
break
attempt += 1
short_name = f"{item.short_name}_{attempt}"
return trash_path