From 69e56ab5a9bcb94c8712834c75eaa5dab12f6f79 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:07:34 +0200 Subject: [PATCH 01/19] Move dataclasses --- e2xauthoring/dataclasses/__init__.py | 18 +++++++ e2xauthoring/dataclasses/gitstatus.py | 12 +++++ e2xauthoring/dataclasses/jsondataclass.py | 7 +++ e2xauthoring/dataclasses/messages.py | 17 +++++++ e2xauthoring/dataclasses/pool.py | 19 +++++++ e2xauthoring/dataclasses/task.py | 13 +++++ e2xauthoring/dataclasses/template.py | 15 ++++++ e2xauthoring/managers/dataclasses/__init__.py | 49 ------------------- 8 files changed, 101 insertions(+), 49 deletions(-) create mode 100644 e2xauthoring/dataclasses/__init__.py create mode 100644 e2xauthoring/dataclasses/gitstatus.py create mode 100644 e2xauthoring/dataclasses/jsondataclass.py create mode 100644 e2xauthoring/dataclasses/messages.py create mode 100644 e2xauthoring/dataclasses/pool.py create mode 100644 e2xauthoring/dataclasses/task.py create mode 100644 e2xauthoring/dataclasses/template.py delete mode 100644 e2xauthoring/managers/dataclasses/__init__.py diff --git a/e2xauthoring/dataclasses/__init__.py b/e2xauthoring/dataclasses/__init__.py new file mode 100644 index 0000000..c1024e0 --- /dev/null +++ b/e2xauthoring/dataclasses/__init__.py @@ -0,0 +1,18 @@ +from .gitstatus import GitStatus +from .jsondataclass import JSONDataClass +from .messages import ErrorMessage, SuccessMessage +from .pool import PoolCollectionRecord, PoolRecord +from .task import TaskRecord +from .template import TemplateCollectionRecord, TemplateRecord + +__all__ = [ + "ErrorMessage", + "GitStatus", + "JSONDataClass", + "TaskRecord", + "PoolRecord", + "PoolCollectionRecord", + "SuccessMessage", + "TemplateRecord", + "TemplateCollectionRecord", +] diff --git a/e2xauthoring/dataclasses/gitstatus.py b/e2xauthoring/dataclasses/gitstatus.py new file mode 100644 index 0000000..82bc355 --- /dev/null +++ b/e2xauthoring/dataclasses/gitstatus.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from .jsondataclass import JSONDataClass + + +@dataclass +class GitStatus(JSONDataClass): + untracked: Optional[List[str]] = field(default_factory=list) + unstaged: Optional[List[str]] = field(default_factory=list) + staged: Optional[List[str]] = field(default_factory=list) + status: Optional[str] = None diff --git a/e2xauthoring/dataclasses/jsondataclass.py b/e2xauthoring/dataclasses/jsondataclass.py new file mode 100644 index 0000000..115579b --- /dev/null +++ b/e2xauthoring/dataclasses/jsondataclass.py @@ -0,0 +1,7 @@ +from dataclasses import asdict, dataclass + + +@dataclass +class JSONDataClass: + def to_json(self): + return asdict(self) diff --git a/e2xauthoring/dataclasses/messages.py b/e2xauthoring/dataclasses/messages.py new file mode 100644 index 0000000..44d294b --- /dev/null +++ b/e2xauthoring/dataclasses/messages.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Any + +from .jsondataclass import JSONDataClass + + +@dataclass +class SuccessMessage(JSONDataClass): + success: bool = True + message: str = "" + data: Any = None + + +@dataclass +class ErrorMessage(JSONDataClass): + success: bool = False + error: str = "" diff --git a/e2xauthoring/dataclasses/pool.py b/e2xauthoring/dataclasses/pool.py new file mode 100644 index 0000000..e80e39c --- /dev/null +++ b/e2xauthoring/dataclasses/pool.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass, field +from typing import List + +from .jsondataclass import JSONDataClass +from .task import TaskRecord + + +@dataclass +class PoolRecord(JSONDataClass): + name: str + base_path: str + n_tasks: int = 0 + tasks: List[TaskRecord] = field(default_factory=list) + is_repo: bool = False + + +@dataclass +class PoolCollectionRecord(JSONDataClass): + pools: List[PoolRecord] = field(default_factory=list) diff --git a/e2xauthoring/dataclasses/task.py b/e2xauthoring/dataclasses/task.py new file mode 100644 index 0000000..38ee209 --- /dev/null +++ b/e2xauthoring/dataclasses/task.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from .gitstatus import GitStatus +from .jsondataclass import JSONDataClass + + +@dataclass +class TaskRecord(JSONDataClass): + name: str + pool: str + points: int + n_questions: int + git_status: GitStatus diff --git a/e2xauthoring/dataclasses/template.py b/e2xauthoring/dataclasses/template.py new file mode 100644 index 0000000..91bf812 --- /dev/null +++ b/e2xauthoring/dataclasses/template.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass, field +from typing import List + +from .jsondataclass import JSONDataClass + + +@dataclass +class TemplateRecord(JSONDataClass): + name: str + variables: List[str] + + +@dataclass +class TemplateCollectionRecord(JSONDataClass): + templates: List[TemplateRecord] = field(default_factory=list) diff --git a/e2xauthoring/managers/dataclasses/__init__.py b/e2xauthoring/managers/dataclasses/__init__.py deleted file mode 100644 index afc971f..0000000 --- a/e2xauthoring/managers/dataclasses/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -import dataclasses -from dataclasses import dataclass -from typing import Any, Dict - - -@dataclass -class JSONDataClass: - def json(self): - return dataclasses.asdict(self) - - -@dataclass -class Template(JSONDataClass): - name: str - - -@dataclass -class TaskPool(JSONDataClass): - name: str - n_tasks: int - is_repo: bool = False - - -@dataclass -class Task(JSONDataClass): - name: str - pool: str - points: int - n_questions: int - git_status: Dict - - -@dataclass -class Exercise(JSONDataClass): - name: str - assignment: str - - -@dataclass -class SuccessMessage(JSONDataClass): - success: bool = True - message: str = "" - data: Any = None - - -@dataclass -class ErrorMessage(JSONDataClass): - success: bool = False - error: str = "" From 60dd4b2707dd574ed797d2ec5e745e723a23041e Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:08:09 +0200 Subject: [PATCH 02/19] Create git repo classes and utils --- e2xauthoring/git/__init__.py | 6 + e2xauthoring/git/base.py | 243 +++++++++++++++++++++++++++++++++++ e2xauthoring/git/factory.py | 34 +++++ e2xauthoring/git/gitrepo.py | 67 ++++++++++ e2xauthoring/git/utils.py | 36 ++++++ 5 files changed, 386 insertions(+) create mode 100644 e2xauthoring/git/__init__.py create mode 100644 e2xauthoring/git/base.py create mode 100644 e2xauthoring/git/factory.py create mode 100644 e2xauthoring/git/gitrepo.py create mode 100644 e2xauthoring/git/utils.py diff --git a/e2xauthoring/git/__init__.py b/e2xauthoring/git/__init__.py new file mode 100644 index 0000000..e7842ef --- /dev/null +++ b/e2xauthoring/git/__init__.py @@ -0,0 +1,6 @@ +from .base import BaseRepo +from .factory import GitRepoFactory +from .gitrepo import GitRepo +from .utils import get_author, set_author + +__all__ = ["BaseRepo", "GitRepo", "GitRepoFactory", "get_author", "set_author"] diff --git a/e2xauthoring/git/base.py b/e2xauthoring/git/base.py new file mode 100644 index 0000000..134743f --- /dev/null +++ b/e2xauthoring/git/base.py @@ -0,0 +1,243 @@ +import os +import time + +from git import Actor, GitCommandError, InvalidGitRepositoryError, Repo + +from ..dataclasses import GitStatus +from ..patterns.observer import Subject + + +class BaseRepo(Subject): + def __init__(self, path, min_update_interval=5): + """ + Initializes the BaseRepo object. + + Args: + path (str): The path of the repository. + min_update_interval (int, optional): The minimum update interval in seconds. + Defaults to 5. + """ + super().__init__() + self.path = path + self.repo = self.get_repo() + self.status = GitStatus( + untracked=[], + unstaged=[], + staged=[], + ) + self.min_update_interval = min_update_interval + self.last_modified = 0 + self.last_full_update = 0 + self.update_status() + + @property + def is_version_controlled(self): + """ + Checks if the path is a Git repository. + + Returns: + bool: True if the path is a Git repository, False otherwise. + """ + return self.repo is not None + + @property + def repo_root(self): + """ + Retrieves the root directory of the Git repository. + + Returns: + str or None: The root directory of the Git repository if found, None otherwise. + """ + if self.repo is not None: + return self.repo.working_tree_dir + return None + + def get_status(self, absolute_paths=False): + """ + Retrieves the status of the repository. + + Args: + absolute_paths (bool, optional): Whether to return absolute paths. Defaults to False. + + Returns: + GitStatus: The status of the repository. + """ + if absolute_paths: + return GitStatus( + untracked=[ + os.path.join(self.repo_root, f) for f in self.status.untracked + ], + unstaged=[ + os.path.join(self.repo_root, f) for f in self.status.unstaged + ], + staged=[os.path.join(self.repo_root, f) for f in self.status.staged], + ) + return self.status + + def get_repo_modification_time(self): + """ + Retrieves the last modification time of the Git repository. + + Returns: + float: The last modification time of the Git repository. + """ + if not self.is_version_controlled: + return 0 + index_path = os.path.join(self.path, ".git", "index") + head_path = os.path.join(self.path, ".git", "HEAD") + timestamp = 0 + if os.path.exists(index_path): + timestamp = max(timestamp, os.path.getmtime(index_path)) + if os.path.exists(head_path): + timestamp = max(timestamp, os.path.getmtime(head_path)) + return timestamp + + def get_repo(self): + """ + Retrieves the Git repository object. + + Returns: + Repo or None: The Git repository object if found, None otherwise. + """ + try: + return Repo(self.path, search_parent_directories=True) + except InvalidGitRepositoryError: + return None + + def refresh_repo(self): + """ + Refreshes the Git repository object. + """ + if time.time() - self.last_full_update < self.min_update_interval: + return + old_path = self.repo.working_tree_dir if self.repo else None + self.repo = self.get_repo() + + if self.repo is None or self.repo.working_tree_dir != old_path: + self.last_modified = 0 + self.update_status() + + def update_status(self): + """ + Updates the status of the repository. + """ + if time.time() - self.last_full_update < self.min_update_interval: + return + self.update_untracked() + self.update_staged_and_unstaged() + self.last_full_update = time.time() + self.notify() + + def update_untracked(self): + """ + Updates the list of untracked files in the repository. + """ + if self.is_version_controlled: + self.status.untracked = self.repo.untracked_files + else: + self.status.untracked = [] + + def update_staged_and_unstaged(self): + """ + Updates the lists of staged and unstaged files in the repository. + """ + if not self.is_version_controlled: + self.status.staged = [] + self.status.unstaged = [] + return + # Update only if the HEAD or index has been modified + mtime = self.get_repo_modification_time() + if mtime > self.last_modified: + self.status.unstaged = [item.a_path for item in self.repo.index.diff(None)] + if self.repo.head.is_valid(): + self.status.staged = [ + item.a_path for item in self.repo.index.diff("HEAD") + ] + else: + self.status.staged = [key[0] for key in self.repo.index.entries.keys()] + self.last_modified = mtime + + def initialize_repo(self): + """ + Initializes a Git repository. + """ + if self.is_version_controlled: + return + self.repo = Repo.init(self.path) + self.last_modified = 0 + self.last_full_update = 0 + self.update_status() + + def add(self, path: str) -> bool: + """ + Adds a file to the repository. + + Args: + path (str): The path of the file to be added. + + Returns: + bool: True if the file was added, False otherwise. + """ + if not self.is_version_controlled: + return False + path = os.path.relpath(os.path.abspath(path), start=self.repo_root) + self.repo.git.add(path) + return True + + def commit( + self, + path: str, + add_if_untracked=False, + message: str = None, + author: Actor = None, + ) -> bool: + """ + Commits changes to the repository. + + Args: + path (str): The path of the file to be committed. + add_if_untracked (bool, optional): Whether to add the file if it is untracked. + Defaults to False. + message (str, optional): The commit message. If not provided, a default message will be + used. Defaults to None. + author (Actor, optional): The author of the commit. Defaults to None. + + Returns: + bool: True if the commit was successful, False otherwise. + """ + if not self.is_version_controlled: + return False + relpath = os.path.relpath(os.path.abspath(path), start=self.repo_root) + author_string = ( + f"{author.name} <{author.email}>" if author is not None else None + ) + try: + if add_if_untracked: + self.add(path) + if message is None: + message = f"Update {relpath}" + self.repo.git.commit(relpath, message=message, author=author_string) + except GitCommandError: + return False + return True + + def diff(self, file_path: str, color: bool = True, html=True) -> str: + """ + Generate a diff for the specified file. + + Args: + file_path (str): The path of the file to generate the diff for. + color (bool, optional): Whether to include color in the diff. Defaults to True. + html (bool, optional): Whether to format the diff as HTML. Defaults to True. + + Returns: + str: The generated diff. + """ + if not self.is_version_controlled: + return "" + diff = self.repo.git.diff( + os.path.relpath(file_path, start=self.repo.working_tree_dir), color=color + ) + if html: + diff.replace("\n", "
") + return diff diff --git a/e2xauthoring/git/factory.py b/e2xauthoring/git/factory.py new file mode 100644 index 0000000..bcfd1e1 --- /dev/null +++ b/e2xauthoring/git/factory.py @@ -0,0 +1,34 @@ +import os +from typing import Dict + +from ..utils.pathutils import is_parent_path +from .gitrepo import GitRepo + + +class GitRepoFactory: + _instances: Dict[str, GitRepo] = dict() + + @staticmethod + def get_instance(path: str) -> GitRepo: + path = os.path.abspath(path) + if path in GitRepoFactory._instances: + return GitRepoFactory._instances[path] + for existing_path in GitRepoFactory._instances.keys(): + if is_parent_path(existing_path, path): + return GitRepoFactory._instances[existing_path] + instance = GitRepo(path) + if instance.is_version_controlled: + GitRepoFactory._instances[instance.repo.working_tree_dir] = instance + else: + GitRepoFactory._instances[path] = instance + return instance + + @staticmethod + def remove_instance(path: str) -> None: + path = os.path.abspath(path) + if path in GitRepoFactory._instances: + del GitRepoFactory._instances[path] + + @staticmethod + def remove_all_instances() -> None: + GitRepoFactory._instances = dict() diff --git a/e2xauthoring/git/gitrepo.py b/e2xauthoring/git/gitrepo.py new file mode 100644 index 0000000..e8abde2 --- /dev/null +++ b/e2xauthoring/git/gitrepo.py @@ -0,0 +1,67 @@ +import os +import shutil + +from git import Actor + +from ..dataclasses import GitStatus +from ..utils.pathutils import is_parent_path +from .base import BaseRepo + +GITIGNORE = ".gitignore" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +class GitRepo(BaseRepo): + def _copy_gitignore(self): + here = os.path.dirname(__file__) + shutil.copy( + os.path.join(here, "..", "assets", GITIGNORE), + os.path.join(self.path, GITIGNORE), + ) + + def initialize_repo(self, exist_ok: bool = True, author: Actor = None): + """ + Initializes a Git repository. + """ + if not exist_ok and self.is_version_controlled: + raise ValueError( + f"A repository already exists at {self.repo.working_tree_dir}" + ) + self._copy_gitignore() + super().initialize_repo() + self.commit( + path=os.path.join(self.path, GITIGNORE), + message="Add .gitignore", + add_if_untracked=True, + author=author, + ) + + def get_status_of_path(self, path: str) -> GitStatus: + """ + Returns the GitStatus of the specified path. + + Args: + path (str): The path to check the status of. + + Returns: + GitStatus: The GitStatus object containing the status of the path. + + """ + status = self.get_status(absolute_paths=True) + return GitStatus( + untracked=[ + os.path.relpath(f, start=path) + for f in status.untracked + if is_parent_path(parent_path=path, child_path=f) + ], + unstaged=[ + os.path.relpath(f, start=path) + for f in status.unstaged + if is_parent_path(parent_path=path, child_path=f) + ], + staged=[ + os.path.relpath(f, start=path) + for f in status.staged + if is_parent_path(parent_path=path, child_path=f) + ], + ) diff --git a/e2xauthoring/git/utils.py b/e2xauthoring/git/utils.py new file mode 100644 index 0000000..f28d0ad --- /dev/null +++ b/e2xauthoring/git/utils.py @@ -0,0 +1,36 @@ +from typing import Dict, Union + +from git import Git, GitCommandError + + +def get_author() -> Dict[str, str]: + """Get the current global git author + + Returns: + Dict[str, str]: A dictionary containing the name and email address of the author + """ + try: + return dict( + name=Git().config(["--global", "user.name"]), + email=Git().config(["--global", "user.email"]), + ) + except GitCommandError: + pass + + +def set_author(name: str, email: str) -> Dict[str, Union[str, bool]]: + """Set the global git author + + Args: + name (str): The name of the author + email (str): The email address of the author + + Returns: + Dict[str, Union[str, bool]]: A dictionary containing status information + """ + try: + Git().config(["--global", "user.name", name]) + Git().config(["--global", "user.email", email]) + return dict(success=True) + except GitCommandError: + return dict(success=False, message="There was an error setting the author") From 5f02b2f4aaa05f5040a215b118a5142908a9a721 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:08:27 +0200 Subject: [PATCH 03/19] Implement observer pattern --- e2xauthoring/patterns/__init__.py | 3 +++ e2xauthoring/patterns/observer.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 e2xauthoring/patterns/__init__.py create mode 100644 e2xauthoring/patterns/observer.py diff --git a/e2xauthoring/patterns/__init__.py b/e2xauthoring/patterns/__init__.py new file mode 100644 index 0000000..97bc78b --- /dev/null +++ b/e2xauthoring/patterns/__init__.py @@ -0,0 +1,3 @@ +from .observer import Observer, Subject + +__all__ = ["Observer", "Subject"] diff --git a/e2xauthoring/patterns/observer.py b/e2xauthoring/patterns/observer.py new file mode 100644 index 0000000..afe67d1 --- /dev/null +++ b/e2xauthoring/patterns/observer.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod +from typing import List + + +class Observer(ABC): + @abstractmethod + def update(self, subject: "Subject"): + pass + + +class Subject: + def __init__(self): + self._observers: List[Observer] = [] + + def attach(self, observer: Observer): + self._observers.append(observer) + + def detach(self, observer: Observer): + self._observers.remove(observer) + + def notify(self): + for observer in self._observers: + observer.update(self) From 763deea77f32c98d82c485417e292953d5dadb8c Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:08:56 +0200 Subject: [PATCH 04/19] Add models for tasks, pools and templates --- e2xauthoring/models/__init__.py | 13 ++ e2xauthoring/models/pool.py | 177 +++++++++++++++++++ e2xauthoring/models/poolcollection.py | 134 ++++++++++++++ e2xauthoring/models/task.py | 204 ++++++++++++++++++++++ e2xauthoring/models/template.py | 82 +++++++++ e2xauthoring/models/templatecollection.py | 93 ++++++++++ 6 files changed, 703 insertions(+) create mode 100644 e2xauthoring/models/__init__.py create mode 100644 e2xauthoring/models/pool.py create mode 100644 e2xauthoring/models/poolcollection.py create mode 100644 e2xauthoring/models/task.py create mode 100644 e2xauthoring/models/template.py create mode 100644 e2xauthoring/models/templatecollection.py diff --git a/e2xauthoring/models/__init__.py b/e2xauthoring/models/__init__.py new file mode 100644 index 0000000..9bac435 --- /dev/null +++ b/e2xauthoring/models/__init__.py @@ -0,0 +1,13 @@ +from .pool import Pool +from .poolcollection import PoolCollection +from .task import Task +from .template import Template +from .templatecollection import TemplateCollection + +__all__ = [ + "Pool", + "PoolCollection", + "Task", + "Template", + "TemplateCollection", +] diff --git a/e2xauthoring/models/pool.py b/e2xauthoring/models/pool.py new file mode 100644 index 0000000..b59baa0 --- /dev/null +++ b/e2xauthoring/models/pool.py @@ -0,0 +1,177 @@ +import glob +import os +import shutil +from typing import Dict + +from git import Actor + +from ..dataclasses import PoolRecord +from ..git import GitRepo, GitRepoFactory, get_author +from ..patterns import Observer +from .task import Task + + +class Pool(Observer): + name: str + base_path: str + path: str + repo: GitRepo + tasks: Dict[str, Task] + + def __init__(self, name: str, base_path: str): + self.name = name + self.base_path = base_path + self.path = os.path.join(base_path, name) + self.repo = GitRepoFactory.get_instance(self.path) + self.repo.attach(self) + self._is_version_controlled = self.repo.is_version_controlled + self.tasks = self.init_tasks() + + def __getitem__(self, key): + return self.tasks[key] + + def __contains__(self, key): + return key in self.tasks + + def update(self, subject: GitRepo): + self._is_version_controlled = subject.is_version_controlled + + @staticmethod + def create(name: str, base_path: str, init_repo: bool = False): + pool_path = os.path.join(base_path, name) + assert not os.path.exists(pool_path), f"Pool {name} already exists" + os.makedirs(pool_path, exist_ok=True) + pool = Pool(name=name, base_path=base_path) + if init_repo: + pool.turn_into_repository() + return pool + + def remove(self): + assert os.path.exists(self.path), f"Pool {self.name} does not exist" + os.rmdir(self.path) + self.repo.detach(self) + + def copy(self, new_name: str): + src_path = self.path + dst_path = os.path.join(self.base_path, new_name) + assert not os.path.exists(dst_path), f"Pool {new_name} already exists" + # Exclude the .git directory + shutil.copytree(src_path, dst_path, ignore=shutil.ignore_patterns(".git")) + return Pool(name=new_name, base_path=self.base_path) + + def rename(self, new_name: str): + new_path = os.path.join(self.base_path, new_name) + assert not os.path.exists(new_path), f"Pool {new_name} already exists" + os.rename(self.path, new_path) + self.name = new_name + self.path = new_path + self.repo.update_status() + + def turn_into_repository(self): + self.repo = GitRepoFactory.get_instance(self.path) + self.repo.attach(self) + self.repo.initialize_repo(exist_ok=True, author=Actor(**get_author())) + + def is_version_controlled(self) -> bool: + return self._is_version_controlled + + def is_task_path(self, path): + task_name = os.path.relpath(os.path.dirname(path), start=self.path) + notebook_name = os.path.splitext(os.path.basename(path))[0] + return task_name == notebook_name + + def init_tasks(self): + paths = glob.glob(os.path.join(self.path, "*", "*.ipynb")) + tasks = dict() + for path in paths: + if self.is_task_path(path): + task_name = os.path.relpath(os.path.dirname(path), start=self.path) + tasks[task_name] = Task( + name=task_name, + pool=self.name, + base_path=self.base_path, + repo=self.repo, + ) + + return tasks + + def update_tasks(self): + paths = glob.glob(os.path.join(self.path, "*", "*.ipynb")) + task_names = set( + [ + os.path.relpath(os.path.dirname(path), start=self.path) + for path in paths + if self.is_task_path(path) + ] + ) + existing_names = set(self.tasks.keys()) + deleted = existing_names - task_names + added = task_names - existing_names + for deleted_task in deleted: + del self.tasks[deleted_task] + for added_task in added: + self.tasks[added_task] = Task( + name=added_task, + pool=self.name, + base_path=self.base_path, + repo=self.repo, + ) + + def add_task(self, name: str, kernel_name: str = None): + task_path = os.path.join(self.path, name) + assert not os.path.exists( + task_path + ), f"Task {name} already exists in pool {self.name}" + task = Task.create( + name=name, + pool=self.name, + base_path=self.base_path, + repo=self.repo, + kernel_name=kernel_name, + ) + self.tasks[name] = task + + def remove_task(self, name: str): + assert name in self.tasks, f"Task {name} does not exist in pool {self.name}" + task = self.tasks.get(name) + task.remove() + del self.tasks[name] + + def copy_task(self, name: str, new_name: str): + assert name in self.tasks, f"Task {name} does not exist in pool {self.name}" + task = self.tasks.get(name) + new_task = task.copy(new_name) + self.tasks[new_name] = new_task + + def rename_task(self, name: str, new_name: str): + assert name in self.tasks, f"Task {name} does not exist in pool {self.name}" + task = self.tasks.get(name) + task.rename(new_name) + self.tasks[new_name] = task + del self.tasks[name] + + def commit_task(self, task_name: str, message: str): + assert task_name in self.tasks, f"No task with the name {task_name} exists." + assert ( + self.repo.is_version_controlled + ), f"Pool {self.name} is not version controlled." + task = self.tasks[task_name] + return self.repo.commit(task.path, add_if_untracked=True, message=message) + + def to_dataclass(self, include_git_status=False) -> PoolRecord: + self.update_tasks() + self.repo.refresh_repo() + tasks = [ + task.to_dataclass(include_git_status=include_git_status) + for task in self.tasks.values() + ] + return PoolRecord( + name=self.name, + base_path=self.base_path, + tasks=tasks, + n_tasks=len(tasks), + is_repo=self.is_version_controlled(), + ) + + def to_json(self, include_git_status=False): + return self.to_dataclass(include_git_status=include_git_status).to_json() diff --git a/e2xauthoring/models/poolcollection.py b/e2xauthoring/models/poolcollection.py new file mode 100644 index 0000000..4c56813 --- /dev/null +++ b/e2xauthoring/models/poolcollection.py @@ -0,0 +1,134 @@ +import glob +import os +from typing import Dict, List + +from nbgrader.coursedir import CourseDirectory +from traitlets import Unicode +from traitlets.config import LoggingConfigurable + +from ..dataclasses import PoolCollectionRecord +from .pool import Pool + + +class PoolCollection(LoggingConfigurable): + pools: Dict[str, Pool] + coursedir: CourseDirectory + + directory = Unicode("pools", help="Directory where pools are stored").tag( + config=True + ) + + _instance = None + _initialized = False + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(PoolCollection, cls).__new__(cls) + return cls._instance + + def __init__(self, coursedir: CourseDirectory): + if not self._initialized: + self.coursedir = coursedir + self.pools = dict() + self.init_pools() + self.__class__._initialized = True + + def __getitem__(self, key): + return self.pools[key] + + def __contains__(self, key): + return key in self.pools + + @property + def pool_path(self): + return self.coursedir.format_path(self.directory, ".", ".") + + def get_pool_paths(self) -> List[str]: + paths = glob.glob(os.path.join(self.pool_path, "*")) + return [os.path.basename(path) for path in paths if os.path.isdir(path)] + + def init_pool(self, pool_name: str): + if pool_name not in self.pools: + self.pools[pool_name] = Pool(name=pool_name, base_path=self.pool_path) + + def init_pools(self): + pool_names = self.get_pool_paths() + for pool_name in pool_names: + self.init_pool(pool_name) + + def update_pools(self): + pool_names = self.get_pool_paths() + deleted_pools = set(self.pools.keys()) - set(pool_names) + for pool_name in deleted_pools: + del self.pools[pool_name] + added_pools = set(pool_names) - set(self.pools.keys()) + for pool_name in added_pools: + self.init_pool(pool_name) + # For each pool that is not a repository, check if it is now a repository + for pool in self.pools.values(): + if not pool.is_version_controlled(): + pool.repo.get_repo() + if pool.is_version_controlled(): + pool.repo.update_status() + + def add_pool(self, pool_name: str, init_repo: bool = False): + pool_path = os.path.join(self.pool_path, pool_name) + assert not os.path.exists(pool_path), f"Pool {pool_name} already exists" + pool = Pool.create( + name=pool_name, + base_path=self.pool_path, + init_repo=init_repo, + ) + self.pools[pool_name] = pool + + def remove_pool(self, pool_name: str): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + pool.remove() + del self.pools[pool_name] + + def copy_pool(self, pool_name: str, new_name: str): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + new_pool = pool.copy(new_name) + self.pools[new_name] = new_pool + + def rename_pool(self, pool_name: str, new_name: str): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + pool.rename(new_name) + self.pools[new_name] = pool + del self.pools[pool_name] + + def add_task(self, pool_name: str, task_name: str, kernel_name: str = None): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + pool.add_task(task_name, kernel_name=kernel_name) + + def remove_task(self, pool_name: str, task_name: str): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + pool.remove_task(task_name) + + def copy_task(self, pool_name: str, task_name: str, new_name: str): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + pool.copy_task(task_name, new_name) + + def rename_task(self, pool_name: str, task_name: str, new_name: str): + assert pool_name in self.pools, f"Pool {pool_name} does not exist" + pool = self.pools[pool_name] + pool.rename_task(task_name, new_name) + + def to_dataclass(self, include_git_status=False) -> PoolCollectionRecord: + self.update_pools() + + return PoolCollectionRecord( + pools=[ + pool.to_dataclass(include_git_status=include_git_status) + for pool in self.pools.values() + ] + ) + + def to_json(self, include_git_status=False): + return self.to_dataclass(include_git_status=include_git_status).to_json() diff --git a/e2xauthoring/models/task.py b/e2xauthoring/models/task.py new file mode 100644 index 0000000..ab6ce5d --- /dev/null +++ b/e2xauthoring/models/task.py @@ -0,0 +1,204 @@ +import os +import shutil +import time +from typing import Dict + +import nbformat +from e2xcore.utils.nbgrader_cells import get_points, is_grade, new_read_only_cell +from jupyter_client.kernelspec import KernelSpecManager + +from ..dataclasses import GitStatus, TaskRecord +from ..git import GitRepo +from ..patterns import Observer +from ..utils.pathutils import list_files + + +def new_task_notebook( + name: str, kernel_name: str = None +) -> nbformat.notebooknode.NotebookNode: + metadata = dict(nbassignment=dict(type="task")) + if kernel_name is not None: + kernel_spec = KernelSpecManager().get_kernel_spec(kernel_name) + metadata["kernelspec"] = dict( + name=kernel_name, + display_name=kernel_spec.display_name, + language=kernel_spec.language, + ) + nb = nbformat.v4.new_notebook(metadata=metadata) + cell = new_read_only_cell( + grade_id=f"{name}_Header", + source=( + f"# {name}\n" + "Here you should give a brief description of the task.\n" + "Then add questions via the menu above.\n" + "A task should be self contained and not rely on other tasks." + ), + ) + nb.cells.append(cell) + return nb + + +class Task(Observer): + name: str + pool: str + path: str + n_questions: int + points: int + git_status: Dict[str, str] + last_modified: float + last_updated: float + repo: GitRepo + + def __init__(self, name: str, pool: str, base_path: str, repo: GitRepo): + self.name = name + self.pool = pool + self.path = os.path.join(base_path, pool, name) + self.repo = repo + self.repo.attach(self) + self.last_modified = 0 + self.last_updated = self.repo.last_full_update + self.update_task_info() + self.full_git_status = self.repo.get_status_of_path(self.path) + self.file_dict = list_files(self.path) + + def update(self, subject: GitRepo): + self.last_updated = subject.last_full_update + self.full_git_status = subject.get_status_of_path(self.path) + self.file_dict = list_files(self.path) + + @property + def git_status(self): + if not self.repo.is_version_controlled: + return dict(status="not version controlled") + elif ( + len( + self.full_git_status.unstaged + + self.full_git_status.staged + + self.full_git_status.untracked + ) + > 0 + ): + return dict(status="modified") + else: + return dict(status="unchanged") + + def check_for_changes(self): + current_file_dict = list_files(self.path) + if set(current_file_dict.items()) != set(self.file_dict.items()): + self.repo.update_status() + elif self.last_updated < self.repo.last_full_update: + self.update(self.repo) + + @staticmethod + def create( + name: str, pool: str, base_path: str, repo: GitRepo, kernel_name: str = None + ): + task_path = os.path.join(base_path, pool, name) + assert not os.path.exists( + task_path + ), f"Task {name} already exists in pool {pool}" + os.makedirs(os.path.join(task_path, "data"), exist_ok=True) + os.makedirs(os.path.join(task_path, "img"), exist_ok=True) + nb = new_task_notebook(name, kernel_name) + nbformat.write(nb, os.path.join(task_path, f"{name}.ipynb")) + # Sleep to ensure that the file is written before the repo is updated + time.sleep(0.5) + + return Task(name, pool, base_path, repo) + + def remove(self): + task_path = self.path + assert os.path.exists( + task_path + ), f"Task {self.name} does not exist in pool {self.pool}" + shutil.rmtree(task_path) + self.repo.detach(self) + self.repo.update_status() + + def copy(self, new_name: str): + old_path = self.path + new_path = os.path.join(os.path.dirname(old_path), new_name) + assert not os.path.exists(new_path), f"Task {new_name} already exists" + shutil.copytree(old_path, new_path) + shutil.move( + os.path.join(new_path, f"{self.name}.ipynb"), + os.path.join(new_path, f"{new_name}.ipynb"), + ) + nb = nbformat.read( + os.path.join(new_path, f"{new_name}.ipynb"), as_version=nbformat.NO_CONVERT + ) + for cell in nb.cells: + if is_grade(cell): + cell.source = cell.source.replace(self.name, new_name) + cell.metadata.nbgrader.grade_id = ( + cell.metadata.nbgrader.grade_id.replace(self.name, new_name) + ) + nbformat.write(nb, os.path.join(new_path, f"{new_name}.ipynb")) + self.repo.update_status() + return Task(new_name, self.pool, os.path.dirname(old_path), self.repo) + + def rename(self, new_name: str): + old_path = self.path + new_path = os.path.join(os.path.dirname(old_path), new_name) + assert not os.path.exists(new_path), f"Task {new_name} already exists" + shutil.move(old_path, new_path) + self.path = new_path + self.name = new_name + self.repo.update_status() + + @property + def notebook_file(self): + return os.path.join(self.path, f"{self.name}.ipynb") + + @property + def data_path(self): + return os.path.join(self.path, "data") + + @property + def image_path(self): + return os.path.join(self.path, "img") + + @property + def notebook_file_exists(self): + return os.path.isfile(self.notebook_file) + + @property + def is_dirty(self): + if self.notebook_file_exists: + return self.last_modified < os.path.getmtime(self.notebook_file) + return False + + def update_task_info(self): + if self.is_dirty: + nb = nbformat.read(self.notebook_file, as_version=nbformat.NO_CONVERT) + last_modified = os.path.getmtime(self.notebook_file) + if self.last_modified < last_modified: + points = [get_points(cell) for cell in nb.cells if is_grade(cell)] + self.points = sum(points) + self.n_questions = len(points) + self.last_modified = last_modified + + def to_dataclass(self, include_git_status=False) -> TaskRecord: + if self.is_dirty: + self.update_task_info() + status = GitStatus( + status=self.git_status["status"], + ) + if include_git_status: + self.check_for_changes() + status = GitStatus( + status=self.git_status["status"], + staged=self.full_git_status.staged, + unstaged=self.full_git_status.unstaged, + untracked=self.full_git_status.untracked, + ) + return TaskRecord( + name=self.name, + pool=self.pool, + points=self.points, + n_questions=self.n_questions, + git_status=status, + ) + + def to_json(self, include_git_status=False): + return self.to_dataclass(include_git_status).to_json() diff --git a/e2xauthoring/models/template.py b/e2xauthoring/models/template.py new file mode 100644 index 0000000..565af8e --- /dev/null +++ b/e2xauthoring/models/template.py @@ -0,0 +1,82 @@ +import os +import shutil + +import nbformat +from e2xcore.utils.nbgrader_cells import new_read_only_cell +from nbformat.v4 import new_notebook + +from ..dataclasses import TemplateRecord +from ..utils.notebookvariableextractor import NotebookVariableExtractor + + +class Template: + name: str + path: str + + def __init__(self, name: str, base_path: str): + self.name = name + self.path = os.path.join(base_path, name) + + @property + def notebook_file(self): + return os.path.join(self.path, f"{self.name}.ipynb") + + @staticmethod + def create(name: str, base_path: str) -> "Template": + path = os.path.join(base_path, name) + assert not os.path.exists(path), f"Template {name} already exists" + os.makedirs(path, exist_ok=True) + nb = new_notebook(metadata=dict(nbassignment=dict(type="template"))) + cell = new_read_only_cell( + grade_id="HeaderA", + source=( + "### This is a header cell\n\n" + "It will always appear at the top of the notebook" + ), + ) + cell.metadata["nbassignment"] = dict(type="header") + nb.cells.append(cell) + nbformat.write(nb, os.path.join(path, f"{name}.ipynb")) + return Template(name=name, base_path=base_path) + + def remove(self): + assert os.path.exists(self.path), f"The template {self.name} does not exist." + shutil.rmtree(self.path) + + def copy(self, new_name: str): + new_template_path = os.path.join(os.path.dirname(self.path), new_name) + assert not os.path.exists( + new_template_path + ), f"Template {new_name} already exists" + shutil.copytree(self.path, new_template_path) + os.rename( + os.path.join(new_template_path, f"{self.name}.ipynb"), + os.path.join(new_template_path, f"{new_name}.ipynb"), + ) + return Template(name=new_name, base_path=os.path.dirname(self.path)) + + def rename(self, new_name: str): + new_template_path = os.path.join(os.path.dirname(self.path), new_name) + assert not os.path.exists( + new_template_path + ), f"Template {new_name} already exists" + os.rename(self.path, new_template_path) + old_notebook_file = self.notebook_file + new_notebook_file = os.path.join( + os.path.dirname(old_notebook_file), f"{new_name}.ipynb" + ) + os.rename(old_notebook_file, new_notebook_file) + self.name = new_name + self.path = new_template_path + + def list_variables(self): + assert os.path.exists( + self.notebook_file + ), f"The template {self.name} does not exist." + return NotebookVariableExtractor().extract(self.notebook_file) + + def to_dataclass(self) -> TemplateRecord: + return TemplateRecord(name=self.name, variables=self.list_variables()) + + def to_json(self): + return self.to_dataclass().to_json() diff --git a/e2xauthoring/models/templatecollection.py b/e2xauthoring/models/templatecollection.py new file mode 100644 index 0000000..6eae8fe --- /dev/null +++ b/e2xauthoring/models/templatecollection.py @@ -0,0 +1,93 @@ +import glob +import os +from typing import Dict + +from nbgrader.coursedir import CourseDirectory +from traitlets import Unicode +from traitlets.config import LoggingConfigurable + +from ..dataclasses import TemplateCollectionRecord +from .template import Template + + +class TemplateCollection(LoggingConfigurable): + directory = Unicode("templates", help="Directory where templates are stored").tag( + config=True + ) + + templates: Dict[str, Template] + coursedir: CourseDirectory + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(TemplateCollection, cls).__new__(cls) + return cls._instance + + def __init__(self, coursedir: CourseDirectory): + self.coursedir = coursedir + self.templates = dict() + self.init_templates() + + @property + def template_path(self): + return self.coursedir.format_path(self.directory, ".", ".") + + def __getitem__(self, key): + return self.templates[key] + + def __contains__(self, key): + return key in self.templates + + def get_template_paths(self): + paths = glob.glob(os.path.join(self.template_path, "*")) + return [os.path.basename(path) for path in paths if os.path.isdir(path)] + + def init_template(self, template_name: str): + if template_name not in self.templates: + self.templates[template_name] = Template( + name=template_name, base_path=self.template_path + ) + + def init_templates(self): + template_names = self.get_template_paths() + for template_name in template_names: + self.init_template(template_name) + + def update_templates(self): + template_names = self.get_template_paths() + deleted_templates = set(self.templates.keys()) - set(template_names) + for template_name in deleted_templates: + self.templates.pop(template_name) + added_templates = set(template_names) - set(self.templates.keys()) + for template_name in added_templates: + self.init_template(template_name) + + def add_template(self, name: str): + template_path = os.path.join(self.template_path, name) + assert not os.path.exists( + template_path + ), f"Template {name} already exists in templates" + template = Template.create(name=name, base_path=self.template_path) + self.templates[name] = template + + def remove_template(self, name: str): + assert name in self.templates, f"Template {name} does not exist in templates" + template = self.templates.get(name) + template.remove() + del self.templates[name] + + def copy_template(self, name: str, new_name: str): + assert name in self.templates, f"Template {name} does not exist in templates" + template = self.templates.get(name) + new_template = template.copy(new_name) + self.templates[new_name] = new_template + + def to_dataclass(self) -> TemplateCollectionRecord: + self.update_templates() + return TemplateCollectionRecord( + templates=[template.to_dataclass() for template in self.templates.values()] + ) + + def to_json(self): + return self.to_dataclass().to_json() From 80343e09feed56c30e3fd92bf36c52fe47b45e86 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:10:34 +0200 Subject: [PATCH 05/19] Add return type hint --- e2xauthoring/utils/notebookvariableextractor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2xauthoring/utils/notebookvariableextractor.py b/e2xauthoring/utils/notebookvariableextractor.py index b436c6a..d9db89e 100644 --- a/e2xauthoring/utils/notebookvariableextractor.py +++ b/e2xauthoring/utils/notebookvariableextractor.py @@ -1,4 +1,5 @@ import re +from typing import List import nbformat @@ -7,7 +8,7 @@ class NotebookVariableExtractor: def __init__(self): self.__pattern = re.compile(r"{{\s*(\w+)\s*}}") - def extract(self, nb_path): + def extract(self, nb_path) -> List[str]: nb = nbformat.read(nb_path, as_version=4) variables = [] for cell in nb.cells: From e0030fc1af6a9b6dbf4c98e92a1dca42f585c4c5 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:11:06 +0200 Subject: [PATCH 06/19] Use new dataclass in base handler --- e2xauthoring/app/handlers/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/e2xauthoring/app/handlers/base.py b/e2xauthoring/app/handlers/base.py index 952960f..0569a99 100644 --- a/e2xauthoring/app/handlers/base.py +++ b/e2xauthoring/app/handlers/base.py @@ -6,7 +6,7 @@ from nbgrader.server_extensions.formgrader.base import check_xsrf from tornado import web -from e2xauthoring.managers.dataclasses import ErrorMessage, SuccessMessage +from ...dataclasses import ErrorMessage, SuccessMessage def status_msg(method): @@ -103,22 +103,22 @@ async def handle_request(self, request_type: str): @check_xsrf async def get(self): result = await self.handle_request("get") - self.finish(result.json()) + self.finish(result.to_json()) @web.authenticated @check_xsrf async def delete(self): result = await self.handle_request("delete") - self.finish(result.json()) + self.finish(result.to_json()) @web.authenticated @check_xsrf async def put(self): result = await self.handle_request("put") - self.finish(result.json()) + self.finish(result.to_json()) @web.authenticated @check_xsrf async def post(self): result = await self.handle_request("post") - self.finish(result.json()) + self.finish(result.to_json()) From 29dd8dc614cd3d5aa169b92c4bfa87bf44b7d3c3 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:11:52 +0200 Subject: [PATCH 07/19] Change import of gitutils --- e2xauthoring/app/handlers/apihandlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2xauthoring/app/handlers/apihandlers.py b/e2xauthoring/app/handlers/apihandlers.py index f6be42c..6c3b2a3 100644 --- a/e2xauthoring/app/handlers/apihandlers.py +++ b/e2xauthoring/app/handlers/apihandlers.py @@ -6,6 +6,7 @@ from nbgrader.server_extensions.formgrader.base import check_xsrf from tornado import web +from ...git import get_author, set_author from ...managers import ( AssignmentManager, PresetManager, @@ -14,7 +15,6 @@ TemplateManager, WorksheetManager, ) -from ...utils.gitutils import get_author, set_author from .base import ApiManageHandler From 23f5cead80bfdc1fda5603392f3c5bb4d79a2cb5 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:12:11 +0200 Subject: [PATCH 08/19] Update managers --- e2xauthoring/managers/taskmanager.py | 205 ++++------------------- e2xauthoring/managers/taskpoolmanager.py | 99 +++-------- e2xauthoring/managers/templatemanager.py | 68 ++------ 3 files changed, 68 insertions(+), 304 deletions(-) diff --git a/e2xauthoring/managers/taskmanager.py b/e2xauthoring/managers/taskmanager.py index cb873f6..2c19ae3 100644 --- a/e2xauthoring/managers/taskmanager.py +++ b/e2xauthoring/managers/taskmanager.py @@ -1,196 +1,55 @@ -import asyncio import os -import shutil -import nbformat -from e2xcore.utils.nbgrader_cells import is_nbgrader_cell, new_read_only_cell -from nbformat.v4 import new_notebook -from traitlets import Unicode - -from ..utils.gitutils import commit_path, vcs_status +from ..models import PoolCollection from .base import BaseManager -from .dataclasses import Task -from .taskpoolmanager import TaskPoolManager class TaskManager(BaseManager): - directory = Unicode( - "pools", help="The relative directory where the pools are stored" - ) - - async def __get_task_info(self, task, pool): - base_path = os.path.join(self.base_path, pool) - notebooks = [ - file - for file in os.listdir(os.path.join(base_path, task)) - if file.endswith(".ipynb") - ] - - points = 0 - questions = 0 + pools: PoolCollection - for notebook in notebooks: - nb = await asyncio.to_thread( - nbformat.read, - os.path.join(base_path, task, notebook), - as_version=nbformat.NO_CONVERT, - ) - for cell in nb.cells: - if "nbgrader" in cell.metadata and cell.metadata.nbgrader.grade: - points += cell.metadata.nbgrader.points - questions += 1 - return points, questions + def __init__(self, coursedir): + self.pools = PoolCollection(coursedir) def commit(self, pool, task, message): - path = os.path.join(self.base_path, pool, task) - git_status = self.git_status(pool, task) - assert git_status is not None, f"Path {path} is not part of a git repository." - if git_status["status"] == "unchanged": - return "Nothing to commit. No files have been changed." - - commit_okay = commit_path( - git_status["repo"], path, add_if_untracked=True, message=message - ) + assert pool in self.pools, f"No pool with the name {pool} exists." + commit_okay = self.pools[pool].commit_task(task, message) assert commit_okay, "There was an error during the commit process." - def git_status(self, pool, task): - path = os.path.join(self.base_path, pool, task) - git_status = vcs_status(path, relative=True) - if git_status["repo"] is None: - return dict(status="not version controlled") - changed_files = ( - git_status["untracked"] + git_status["unstaged"] + git_status["staged"] - ) - git_status["status"] = "modified" if len(changed_files) > 0 else "unchanged" - return git_status - def git_diff(self, pool, task, file): - path = os.path.join(self.base_path, pool, task, file) - assert os.path.exists(path), f"Path {path} does not exist" - git_status = vcs_status(path) + assert pool in self.pools, f"No pool with the name {pool} exists." assert ( - git_status["repo"] is not None - ), f"Path {path} is not version controlled or not added" - relpath = os.path.relpath(path, start=git_status["repo"].working_tree_dir) - return dict( - path=path, - diff=git_status["repo"] - .git.diff(relpath, color=True) - .replace("\n", "
"), - ) - - async def get(self, pool: str, name: str): - path = os.path.join(self.base_path, pool, name) - assert os.path.exists(path), "The task does not exists" - points, n_questions = await self.__get_task_info(name, pool) - git_status = await asyncio.to_thread(self.git_status, pool, name) - - if "repo" in git_status: - del git_status["repo"] - return Task( - name=name, - pool=pool, - points=points, - n_questions=n_questions, - git_status=git_status, - ) - - def create(self, pool: str, name: str): - assert self.is_valid_name(name), "The name is invalid!" - path = os.path.join(self.base_path, pool, name) - assert not os.path.exists( - path - ), f"A task with the name {name} already exists in the pool {pool}!" - self.log.info(f"Creating new task with name {name}") - os.makedirs(os.path.join(path, "img"), exist_ok=True) - os.makedirs(os.path.join(path, "data"), exist_ok=True) - nb = new_notebook(metadata=dict(nbassignment=dict(type="task"))) - cell = new_read_only_cell( - grade_id=f"{name}_Header", - source=( - f"# {name}\n" - "Here you should give the general information about the task.\n" - "Then add questions via the menu above.\n" - "A task should be self contained" - ), - ) - nb.cells.append(cell) - nbformat.write(nb, os.path.join(path, f"{name}.ipynb")) + task in self.pools[pool] + ), f"No task with the name {task} exists in pool {pool}." + file_path = os.path.join(self.pools[pool][task].path, file) + repo = self.pools[pool].repo + assert repo.is_version_controlled, "The pool is not version controlled." + return repo.diff(file_path) + + def get(self, pool: str, name: str): + assert pool in self.pools, f"No pool with the name {pool} exists." + taskpool = self.pools[pool] + assert name in taskpool, f"No task with the name {name} exists in pool {pool}." + return taskpool[name].to_json(include_git_status=True) + + def create(self, pool: str, name: str, kernel_name: str = None): + self.pools.add_task(pool, name, kernel_name=kernel_name) def remove(self, pool, name): - path = os.path.join(self.base_path, pool, name) - assert os.path.exists( - path - ), f"No task with the name {name} from pool {pool} exists." - shutil.rmtree(path) - - async def list(self, pool): - tasks = [] - path = os.path.join(self.base_path, pool) - assert os.path.exists(path), f"No pool with the name {pool} exists." - - coroutines = [] - task_dirs = self.listdir(os.path.join(self.base_path, pool)) + self.pools.remove_task(pool, name) - for task_dir in task_dirs: - coroutines.append(self.__get_task_info(task_dir, pool)) - coroutines.append(asyncio.to_thread(self.git_status, pool, task_dir)) + def list(self, pool): + assert pool in self.pools, f"No pool with the name {pool} exists." + return self.pools[pool].to_json(include_git_status=True)["tasks"] - results = await asyncio.gather(*coroutines) - - for i, task_dir in enumerate(task_dirs): - points, n_questions = results[i * 2] - git_status = results[i * 2 + 1] - - if "repo" in git_status: - del git_status["repo"] - - tasks.append( - Task( - name=task_dir, - pool=pool, - points=points, - n_questions=n_questions, - git_status=git_status, - ) - ) - - return tasks - - async def list_all(self): - pool_manager = TaskPoolManager(self.coursedir) + def list_all(self): + pools = self.pools.to_json(include_git_status=False) tasks = [] - pool_list = await pool_manager.list() - - # Collect all coroutines - coroutines = [self.list(pool.name) for pool in pool_list] - - # Run them concurrently - results = await asyncio.gather(*coroutines) - - # Flatten the list of results - for pool_tasks in results: - tasks.extend(pool_tasks) - + for pool in pools["pools"]: + tasks.extend(pool["tasks"]) return tasks def copy(self, old_name: str, new_name: str, pool: str = ""): - src = os.path.join(pool, old_name) - dst = os.path.join(pool, new_name) - super().copy(src, dst) - dst_path = os.path.join(self.base_path, dst) - shutil.move( - os.path.join(dst_path, f"{old_name}.ipynb"), - os.path.join(dst_path, f"{new_name}.ipynb"), - ) - nb_path = os.path.join(dst_path, f"{new_name}.ipynb") - nb = nbformat.read(nb_path, as_version=nbformat.NO_CONVERT) - for cell in nb.cells: - if is_nbgrader_cell(cell): - grade_id = cell.metadata.nbgrader.grade_id - cell.metadata.nbgrader.grade_id = grade_id.replace(old_name, new_name) - nbformat.write(nb, nb_path) + self.pools.copy_task(pool, old_name, new_name) def rename(self, old_name: str, new_name: str, pool: str = ""): - self.copy(old_name, new_name, pool) - self.remove(pool, old_name) + self.pools.rename_task(pool, old_name, new_name) diff --git a/e2xauthoring/managers/taskpoolmanager.py b/e2xauthoring/managers/taskpoolmanager.py index d6ecc74..205dd3a 100644 --- a/e2xauthoring/managers/taskpoolmanager.py +++ b/e2xauthoring/managers/taskpoolmanager.py @@ -1,91 +1,38 @@ -import asyncio -import os -import shutil - -from traitlets import Unicode - -from ..utils.gitutils import create_repository, is_version_controlled +from ..models import PoolCollection from .base import BaseManager -from .dataclasses import TaskPool class TaskPoolManager(BaseManager): - directory = Unicode( - "pools", help="The relative directory where the pools are stored" - ) - - async def __get_n_tasks(self, name) -> int: - base_path = os.path.join(self.base_path, name) + pools: PoolCollection - # Offload os.listdir to a thread - directory_list = await asyncio.to_thread(os.listdir, base_path) + def __init__(self, coursedir): + self.pools = PoolCollection(coursedir) - # Filter out directories that start with a dot ('.') - task_count = len([d for d in directory_list if not d.startswith(".")]) - - return task_count + def assert_pool_exists(self, name): + assert name in self.pools, f"The pool {name} does not exist." def turn_into_repository(self, pool): - path = os.path.join(self.base_path, pool) - assert os.path.exists(path) and os.path.isdir( - path - ), f"The pool {pool} does not exist or is not a directory." - repo = create_repository(path) - assert ( - repo is not None - ), f"There was an issue turning the pool {pool} into a repository!" + self.assert_pool_exists(pool) + self.pools[pool].turn_into_repository() def get(self, name: str): - path = os.path.join(self.base_path, name) - assert os.path.exists(path), f"A pool with the name {name} does not exists!" - return TaskPool( - name=name, - n_tasks=self.__get_n_tasks(name), - is_repo=is_version_controlled(path), - ) - - def create(self, name: str, init_repository: bool = False): - assert self.is_valid_name(name), "The name is invalid!" - path = os.path.join(self.base_path, name) - assert not os.path.exists(path), f"A pool with the name {name} already exists!" - os.makedirs(path, exist_ok=True) - if init_repository: - return self.turn_into_repository(name) + self.assert_pool_exists(name) + return self.pools[name].to_json(include_git_status=True) - def remove(self, name): - path = os.path.join(self.base_path, name) - assert os.path.exists(path), f"The task pool {name} does not exist" - shutil.rmtree(path) - - async def list(self): - if not os.path.exists(self.base_path): - self.log.warning("The pool directory does not exist.") - os.makedirs(self.base_path, exist_ok=True) - - pool_dirs = await asyncio.to_thread(self.listdir, self.base_path) - tasks = [] - coroutines = [] + def copy(self, name: str, new_name: str): + self.pools.copy_pool(name, new_name) + return self.pools[new_name].to_json(include_git_status=True) - for pool_dir in pool_dirs: - coroutines.append(self.__get_n_tasks(pool_dir)) - coroutines.append( - asyncio.to_thread( - is_version_controlled, os.path.join(self.base_path, pool_dir) - ) - ) + def rename(self, name: str, new_name: str): + self.pools.rename_pool(name, new_name) + return self.pools[new_name].to_json(include_git_status=True) - results = await asyncio.gather(*coroutines) - - for i, pool_dir in enumerate(pool_dirs): - n_tasks = results[i * 2] - is_repo = results[i * 2 + 1] + def create(self, name: str, init_repository: bool = False): + self.pools.add_pool(name, init_repo=init_repository) + return self.pools[name].to_json(include_git_status=True) - tasks.append( - TaskPool( - name=pool_dir, - n_tasks=n_tasks, - is_repo=is_repo, - ) - ) + def remove(self, name): + self.pools.remove_pool(name) - return tasks + def list(self): + return self.pools.to_json(include_git_status=True)["pools"] diff --git a/e2xauthoring/managers/templatemanager.py b/e2xauthoring/managers/templatemanager.py index 681a1b4..9110398 100644 --- a/e2xauthoring/managers/templatemanager.py +++ b/e2xauthoring/managers/templatemanager.py @@ -1,71 +1,29 @@ -import os -import shutil - -import nbformat -from e2xcore.utils.nbgrader_cells import new_read_only_cell -from nbformat.v4 import new_notebook -from traitlets import Unicode - -from e2xauthoring.utils.notebookvariableextractor import NotebookVariableExtractor - +from ..models import TemplateCollection from .base import BaseManager -from .dataclasses import Template class TemplateManager(BaseManager): - directory = Unicode( - "templates", help="The relative directory where the templates are stored" - ) + templates: TemplateCollection + + def __init__(self, coursedir): + self.templates = TemplateCollection(coursedir) def get(self, name: str): - path = os.path.join(self.base_path, name) - assert os.path.exists(path), f"A template with the name {name} does not exists!" - return Template(name=name) + assert name in self.templates, f"The template {name} does not exist." + return self.templates[name].to_json() def create(self, name: str): - assert self.is_valid_name(name), "The name is invalid!" - path = os.path.join(self.base_path, name) - assert not os.path.exists( - path - ), f"A template with the name {name} already exists!" - self.log.info(f"Creating new template with name {name}") - os.makedirs(os.path.join(self.base_path, name, "img"), exist_ok=True) - os.makedirs(os.path.join(self.base_path, name, "data"), exist_ok=True) - nb = new_notebook(metadata=dict(nbassignment=dict(type="template"))) - cell = new_read_only_cell( - grade_id="HeaderA", - source=( - "### This is a header cell\n\n" - "It will always appear at the top of the notebook" - ), - ) - cell.metadata["nbassignment"] = dict(type="header") - nb.cells.append(cell) - nbformat.write(nb, os.path.join(self.base_path, name, f"{name}.ipynb")) + self.templates.add_template(name) def remove(self, name: str): - path = os.path.join(self.base_path, name) - assert os.path.exists(path), f"The template {name} does not exist." - shutil.rmtree(path) + self.templates.remove_template(name) def list(self): - if not os.path.exists(self.base_path): - self.log.warning("The template directory does not exist.") - os.makedirs(self.base_path, exist_ok=True) - templates = [ - Template(name=template_dir) for template_dir in self.listdir(self.base_path) - ] - return templates + return self.templates.to_json()["templates"] def list_variables(self, name): - path = os.path.join(self.base_path, name, f"{name}.ipynb") - assert os.path.exists(path), f"The template {name} does not exist." - return NotebookVariableExtractor().extract(path) + assert name in self.templates, f"The template {name} does not exist." + return self.templates[name].list_variables() def copy(self, old_name: str, new_name: str): - super().copy(old_name, new_name) - dst_path = os.path.join(self.base_path, new_name) - shutil.move( - os.path.join(dst_path, f"{old_name}.ipynb"), - os.path.join(dst_path, f"{new_name}.ipynb"), - ) + self.templates.copy_template(old_name, new_name) From b0c6048d2bf0077b36addd0786d654bbf42778cf Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:12:27 +0200 Subject: [PATCH 09/19] Add pathutils --- e2xauthoring/utils/pathutils.py | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 e2xauthoring/utils/pathutils.py diff --git a/e2xauthoring/utils/pathutils.py b/e2xauthoring/utils/pathutils.py new file mode 100644 index 0000000..da2b620 --- /dev/null +++ b/e2xauthoring/utils/pathutils.py @@ -0,0 +1,37 @@ +import os + + +def is_parent_path(parent_path: str, child_path: str) -> bool: + """Check if a path is a parent of another path + + Args: + parent_path (str): The potential parent path + child_path (str): The path to test + Returns: + bool: True if child_path is a sub directory of parent_path + """ + parent_path = os.path.abspath(parent_path) + child_path = os.path.abspath(child_path) + return os.path.commonpath([parent_path]) == os.path.commonpath( + [parent_path, child_path] + ) + + +def list_files(path: str) -> dict: + """List all files in all subdirectories recursively + + Args: + path (str): The path to list files from + Returns: + dict: A dictionary of files and their last modified time + """ + files = [ + os.path.relpath(os.path.join(root, file), start=path) + for root, _, files in os.walk(path) + for file in files + if ".ipynb_checkpoints" not in root + ] + file_dict = dict() + for file in files: + file_dict[file] = os.path.getmtime(os.path.join(path, file)) + return file_dict From 7f6e3e61b71b82eae1a9e05f8dd841182cee1e56 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:12:41 +0200 Subject: [PATCH 10/19] Remove gitutils from utils --- e2xauthoring/utils/gitutils.py | 188 --------------------------------- 1 file changed, 188 deletions(-) delete mode 100644 e2xauthoring/utils/gitutils.py diff --git a/e2xauthoring/utils/gitutils.py b/e2xauthoring/utils/gitutils.py deleted file mode 100644 index 0375dbb..0000000 --- a/e2xauthoring/utils/gitutils.py +++ /dev/null @@ -1,188 +0,0 @@ -import os -import shutil -from typing import Dict, List, Union - -from git import Actor, BadName, Git, GitCommandError, InvalidGitRepositoryError, Repo - - -def is_parent_path(parent_path: str, child_path: str) -> bool: - """Check if a path is a parent of another path - - Args: - parent_path (str): The potential parent path - child_path (str): The path to test - Returns: - bool: True if child_path is a sub directory of parent_path - """ - parent_path = os.path.abspath(parent_path) - child_path = os.path.abspath(child_path) - return os.path.commonpath([parent_path]) == os.path.commonpath( - [parent_path, child_path] - ) - - -def is_version_controlled(path: str) -> bool: - """Test whether a path is part of a git repository - - Args: - path (str): Path to check for - - Returns: - bool: True if part of a git repository - """ - try: - Repo(path, search_parent_directories=True) - return True - except InvalidGitRepositoryError: - return False - - -def vcs_status(path: str, relative: bool = False) -> Dict[str, Union[List[str], bool]]: - """Get the version control status of a path - - Args: - path (str): Path to check - relative (bool, optional): Turn paths into relative paths starting at path. - Defaults to False. - - Returns: - dict: A dictionary containing untracked files, unstaged files and staged files - under the path - """ - if not is_version_controlled(path): - return dict(repo=None) - repo = Repo(path, search_parent_directories=True) - # Get the general status of the repository - status = dict( - repo=repo, - untracked=repo.untracked_files, - staged=[], - unstaged=[item.a_path for item in repo.index.diff(None)], - ) - try: - status["staged"] = [item.a_path for item in repo.index.diff("HEAD")] - except BadName: - pass - # Filter out all files that are not a sub path of path - for field in ["untracked", "unstaged", "staged"]: - status[field] = [ - f - for f in status[field] - if is_parent_path(path, os.path.join(repo.working_tree_dir, f)) - ] - if relative: - status[field] = [ - os.path.relpath(os.path.join(repo.working_tree_dir, f), start=path) - for f in status[field] - ] - - return status - - -def commit_path( - repo: Repo, - path: str, - add_if_untracked=False, - message: str = None, - author: Actor = None, -) -> bool: - """Commit all files in a given path - - Args: - repo (Repo): A git repository instance - path (str): The path to commit - add_if_untracked (bool, optional): If untracked files should be added before committing. - Defaults to False. - message (str, optional): The commit message. Defaults to None. - author (Actor, optional): An author object to specify the author of the commit. - If not set the global git author will be used. Defaults to None. - - Returns: - bool: status - """ - path = os.path.relpath(os.path.abspath(path), start=repo.working_tree_dir) - if add_if_untracked: - repo.git.add(path) - if message is None: - message = f"Update {path}" - command = [ - f"-m '{message}'", - ] - if author is not None: - command.append(f"--author='{author.name} <{author.email}'") - command.append(path) - repo.git.commit(command) - return True - - -def create_repository(path: str, exists_ok: bool = True, author: Actor = None) -> Repo: - """Create a repository - Intitializes the repository with a gitignore if it does not exists - - Args: - path (str): The base path of the repository - exists_ok (bool, optional): If the repository already exists do not throw an exception. - Defaults to True. - author (Actor, optional): An author object to specify the author of the commit. - If not set the global git author will be used. Defaults to None. - - Returns: - Repo: _description_ - """ - path = os.path.abspath(path) - repo = None - try: - repo = Repo(path, search_parent_directories=True) - assert exists_ok, ( - f"A repository already exists at {repo.working_tree_dir}." - "Run with option exists_ok=True to ignore." - ) - return repo - except InvalidGitRepositoryError: - repo = Repo.init(path) - here = os.path.dirname(__file__) - gitignore = ".gitignore" - shutil.copy( - os.path.join(here, "..", "assets", gitignore), - os.path.join(repo.working_tree_dir, gitignore), - ) - repo.git.add([gitignore]) - command = [f"-m 'Add {gitignore}'"] - if author is not None: - command.append(f"--author='{author.name} <{author.email}'") - command.append(gitignore) - repo.git.commit(command) - return repo - - -def get_author() -> Dict[str, str]: - """Get the current global git author - - Returns: - Dict[str, str]: A dictionary containing the name and email address of the author - """ - try: - return dict( - name=Git().config(["--global", "user.name"]), - email=Git().config(["--global", "user.email"]), - ) - except GitCommandError: - pass - - -def set_author(name: str, email: str) -> Dict[str, Union[str, bool]]: - """Set the global git author - - Args: - name (str): The name of the author - email (str): The email address of the author - - Returns: - Dict[str, Union[str, bool]]: A dictionary containing status information - """ - try: - Git().config(["--global", "user.name", name]) - Git().config(["--global", "user.email", email]) - return dict(success=True) - except GitCommandError: - return dict(success=False, message="There was an error setting the author") From 797884f7ff674585d33ef012b18b58c79335334b Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:13:15 +0200 Subject: [PATCH 11/19] Add kernel name to create task --- packages/api/src/api.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/api.js b/packages/api/src/api.js index e9827a6..a54aa2c 100644 --- a/packages/api/src/api.js +++ b/packages/api/src/api.js @@ -84,11 +84,12 @@ export const API = { task: name, file: file, }), - create: (pool, name) => + create: (pool, name, kernel_name) => requests.post(TASK_API_ROOT, { action: "create", pool: pool, name: name, + kernel_name: kernel_name, }), rename: (pool, old_name, new_name) => requests.put(TASK_API_ROOT, { From 6a4344631fc73694975424b67cc9bfbaf5b114da Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:13:43 +0200 Subject: [PATCH 12/19] Add kernel name to create task dialog --- .../components/task/dialogs/NewTaskDialog.jsx | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/task/dialogs/NewTaskDialog.jsx b/packages/app/src/components/task/dialogs/NewTaskDialog.jsx index a1b9e0a..e8d7a0b 100644 --- a/packages/app/src/components/task/dialogs/NewTaskDialog.jsx +++ b/packages/app/src/components/task/dialogs/NewTaskDialog.jsx @@ -1,7 +1,13 @@ import React from "react"; import { useFormik } from "formik"; import { useNavigate } from "react-router-dom"; -import { Stack } from "@mui/material"; +import { + Stack, + FormControl, + InputLabel, + Select, + MenuItem, +} from "@mui/material"; import API from "@e2xauthoring/api"; import { FormDialogWithButton } from "../../dialogs/form-dialogs"; @@ -11,13 +17,16 @@ import { getTaskUrl } from "../../../utils/urls"; export default function NewTaskDialog({ pool }) { const navigate = useNavigate(); + const [kernels, setKernels] = React.useState({}); + const formik = useFormik({ initialValues: { name: "", + kernel: "", }, validationSchema: baseSchema, onSubmit: (values) => { - API.tasks.create(pool, values.name).then((status) => { + API.tasks.create(pool, values.name, values.kernel).then((status) => { if (!status.success) { alert(status.error); } else { @@ -27,6 +36,18 @@ export default function NewTaskDialog({ pool }) { }, }); + React.useEffect(() => { + API.kernels.list().then((_kernels) => { + setKernels(_kernels); + if ( + Object.keys(_kernels).length > 0 && + formik.values.kernel.length === 0 + ) { + formik.setFieldValue("kernel", Object.keys(_kernels)[0]); + } + }); + }, [formik]); + return ( + + Kernel + + From e079cf95bdf472cf7ebc0a5b512c7ca0acc30f20 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:14:24 +0200 Subject: [PATCH 13/19] Fix parsing of api response in FileDiff.jsx --- packages/app/src/pages/FileDiff.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/FileDiff.jsx b/packages/app/src/pages/FileDiff.jsx index 1596883..e8a9784 100644 --- a/packages/app/src/pages/FileDiff.jsx +++ b/packages/app/src/pages/FileDiff.jsx @@ -22,7 +22,7 @@ export default function FileDiff() { if (!message.success) { alert(message.error); } else { - setDiff(message.data.diff); + setDiff(message.data); setLoading(false); } }); From 4693b129c9658f8b4c0ea8aa2cf84f221e9d7f5a Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:15:15 +0200 Subject: [PATCH 14/19] Add dependencies to React callbacks --- .../src/components/task/tables/TaskTable.jsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/task/tables/TaskTable.jsx b/packages/app/src/components/task/tables/TaskTable.jsx index c295c8e..607179d 100644 --- a/packages/app/src/components/task/tables/TaskTable.jsx +++ b/packages/app/src/components/task/tables/TaskTable.jsx @@ -52,7 +52,7 @@ export default function TaskTable(props) { const [openCopy, setOpenCopy] = React.useState(false); const [selectedRow, setSelectedRow] = React.useState({}); - const load = () => { + const load = React.useCallback(() => { setLoading(true); API.tasks.list(props.pool).then((message) => { if (message.success) { @@ -64,11 +64,11 @@ export default function TaskTable(props) { setLoading(false); } }); - }; + }, [props.pool]); React.useEffect(() => { load(); - }, []); + }, [load]); const deleteTask = React.useCallback( (pool, task) => () => { @@ -84,18 +84,24 @@ export default function TaskTable(props) { }); }); }, - [] + [confirm, load] ); - const editTask = React.useCallback((row) => () => { - setSelectedRow(row); - setOpenEdit(true); - }); + const editTask = React.useCallback( + (row) => () => { + setSelectedRow(row); + setOpenEdit(true); + }, + [] + ); - const copyTask = React.useCallback((row) => () => { - setSelectedRow(row); - setOpenCopy(true); - }); + const copyTask = React.useCallback( + (row) => () => { + setSelectedRow(row); + setOpenCopy(true); + }, + [] + ); const commitTask = React.useCallback( (row) => () => { @@ -188,7 +194,7 @@ export default function TaskTable(props) { ], }, ], - [deleteTask] + [deleteTask, editTask, commitTask, copyTask] ); return ( From 1e99effc976bb165266cfd31880e5dbb55bd7e55 Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:15:29 +0200 Subject: [PATCH 15/19] Update package-lock.json --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2559da..ad35165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "e2xauthoring", - "version": "0.3.0-dev0", + "version": "0.3.0-dev1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "e2xauthoring", - "version": "0.3.0-dev0", + "version": "0.3.0-dev1", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -24431,7 +24431,7 @@ }, "packages/api": { "name": "@e2xauthoring/api", - "version": "0.3.0-dev0", + "version": "0.3.0-dev1", "license": "MIT", "devDependencies": { "webpack": "^5.73.0", @@ -24440,9 +24440,9 @@ }, "packages/app": { "name": "@e2xauthoring/app", - "version": "0.3.0-dev0", + "version": "0.3.0-dev1", "dependencies": { - "@e2xauthoring/api": "0.3.0-dev0", + "@e2xauthoring/api": "0.3.0-dev1", "@emotion/react": "^11.10.0", "@emotion/styled": "^11.10.0", "@fontsource/roboto": "^4.5.8", @@ -25836,7 +25836,7 @@ "@e2xauthoring/app": { "version": "file:packages/app", "requires": { - "@e2xauthoring/api": "0.3.0-dev0", + "@e2xauthoring/api": "0.3.0-dev1", "@emotion/react": "^11.10.0", "@emotion/styled": "^11.10.0", "@fontsource/roboto": "^4.5.8", From 82f9084803661bc6e0235e166a010c6230ff54ec Mon Sep 17 00:00:00 2001 From: Tim Daniel Metzler Date: Tue, 22 Oct 2024 17:59:16 +0200 Subject: [PATCH 16/19] Add favicon.ico --- packages/app/public/index.html | 3 ++- packages/app/public/static/favicon.ico | Bin 0 -> 34494 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/app/public/static/favicon.ico diff --git a/packages/app/public/index.html b/packages/app/public/index.html index a3ff19f..91b1d46 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -3,7 +3,8 @@ - e²xgrader - Authoring + + e²xauthoring