From e19bb48a4e53e9ac52849f3f8d6cbfdeb7785fba Mon Sep 17 00:00:00 2001
From: Tue Herlau <tuhe@dtu.dk>
Date: Sat, 19 Mar 2022 17:20:11 +0100
Subject: [PATCH] Various updates for 02465 during semester

---
 setup.py                                      |   4 +-
 src/coursebox.egg-info/PKG-INFO               |  32 ++-
 src/coursebox.egg-info/requires.txt           |  12 +-
 .../core/__pycache__/info.cpython-38.pyc      | Bin 9428 -> 9540 bytes
 .../__pycache__/projects_info.cpython-38.pyc  | Bin 4426 -> 5235 bytes
 src/coursebox/core/info.py                    |  94 +++++--
 src/coursebox/core/projects.py                | 109 ++++++--
 src/coursebox/core/projects_info.py           | 121 +++++++--
 ...homepage_lectures_exercises.cpython-38.pyc | Bin 13393 -> 14370 bytes
 .../material/homepage_lectures_exercises.py   | 245 +++++++++++++++---
 10 files changed, 501 insertions(+), 116 deletions(-)

diff --git a/setup.py b/setup.py
index 5d1e12e..e5eb159 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
 # beamer-slider
 setuptools.setup(
     name="coursebox",
-    version="0.1.2",
+    version="0.1.4",
     author="Tue Herlau",
     author_email="tuhe@dtu.dk",
     description="A course management system currently used at DTU",
@@ -30,5 +30,5 @@ setuptools.setup(
     package_dir={"": "src"},
     packages=setuptools.find_packages(where="src"),
     python_requires=">=3.8",
-    install_requires=['numpy','pycode_similar','tika','openpyxl', 'xlwings','matplotlib','langdetect','jinjafy','beamer-slider','tinydb'],
+    install_requires=['numpy','pycode_similar','tika','openpyxl', 'xlwings','matplotlib','langdetect','beamer-slider','tinydb'],
 )
diff --git a/src/coursebox.egg-info/PKG-INFO b/src/coursebox.egg-info/PKG-INFO
index f983e78..a813cf8 100644
--- a/src/coursebox.egg-info/PKG-INFO
+++ b/src/coursebox.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: coursebox
-Version: 0.1.1
+Version: 0.1.4
 Summary: A course management system currently used at DTU
 Home-page: https://lab.compute.dtu.dk/tuhe/coursebox
 Author: Tue Herlau
@@ -16,6 +16,34 @@ Description-Content-Type: text/markdown
 License-File: LICENSE
 
 # Coursebox DTU
+DTU course management software.
 
-DTU course management software. 
+## Installation
+```terminal
+pip install coursebox
+```
+## What it can do 
+ - Single semester-dependent configuration file
+ - Integrates with DTU Inside/DTU Learn
+ - Distribution/evalauation of project reports in Learn-compatible format
+ - Quiz-generation in DTU Learn/Beamer friendly format
+ - Automatic website/syllabus generation 
+ - Automatic generation of lectures handouts/exercises (you don't have to track dynamic content like dates/lecture titles; it is all in the configuration)
+ - Easy compilation to 2/5 day formats (Continuous education)
+
+## Usage
+Coursebox requires a specific directory structure. It is easier to start with an existing course and adapt to your needs. Please contact me at tuhe@dtu.dk for more information.
+
+## Citing
+```bibtex
+@online{coursebox,
+	title={Coursebox (0.1.1): \texttt{pip install coursebox}},
+	url={https://lab.compute.dtu.dk/tuhe/coursebox},
+	urldate = {2021-09-07}, 
+	month={9},
+	publisher={Technical University of Denmark (DTU)},
+	author={Tue Herlau},
+	year={2021},
+}
+```
 
diff --git a/src/coursebox.egg-info/requires.txt b/src/coursebox.egg-info/requires.txt
index 6391557..0a85667 100644
--- a/src/coursebox.egg-info/requires.txt
+++ b/src/coursebox.egg-info/requires.txt
@@ -1,13 +1,9 @@
-openpyxl
+numpy
+pycode_similar
 tika
+openpyxl
 xlwings
-pybtex
-langdetect
-wexpect
-pexpect
 matplotlib
-numpy
-pycode_similar
-jinjafy
+langdetect
 beamer-slider
 tinydb
diff --git a/src/coursebox/core/__pycache__/info.cpython-38.pyc b/src/coursebox/core/__pycache__/info.cpython-38.pyc
index 1d680dd6b8885df10e21e68905c1c7757f176b18..e0ab976224268ffd731b58532967876fc91bff4b 100644
GIT binary patch
delta 4435
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

delta 4401
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
K<4>w-b^bqwy5MX8

diff --git a/src/coursebox/core/__pycache__/projects_info.cpython-38.pyc b/src/coursebox/core/__pycache__/projects_info.cpython-38.pyc
index 4924025fb95a5691dc63e256c55f5a0b44df0bbd..3c221ed674c47518426218c8f64be90b275198d7 100644
GIT binary patch
literal 5235
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
B;E@0T

delta 2425
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
DT>K1*

diff --git a/src/coursebox/core/info.py b/src/coursebox/core/info.py
index ba913e1..a80d792 100644
--- a/src/coursebox/core/info.py
+++ b/src/coursebox/core/info.py
@@ -12,10 +12,14 @@ from coursebox.core.info_paths import core_conf
 # import pybtex.database.input.bibtex
 # import pybtex.plugin
 # import io
+from line_profiler_pycharm import profile
+import time
 
-
+@profile
 def xlsx_to_dicts(xlsx_file,sheet=None, as_dict_list=False):
-    wb = openpyxl.load_workbook(xlsx_file, data_only=True)
+    # print("Loading...", xlsx_file, sheet, as_dict_list)
+    t0 = time.time()
+    wb = openpyxl.load_workbook(xlsx_file, data_only=True, read_only=True)
     if not sheet:
         ws = wb.worksheets[0]
     else:
@@ -24,26 +28,65 @@ def xlsx_to_dicts(xlsx_file,sheet=None, as_dict_list=False):
             return None
         else:
             ws = ws.pop()
-    dd = []
-    key_cols = [j for j in range(ws.max_column) if ws.cell(row=1, column=j + 1).value is not None]
-    for i in range(1, ws.max_row):
-        rdict = {}
-        if not any( [ws.cell(row=i+1, column=j+1).value is not None for j in key_cols] ):
-            continue
-        for j in key_cols:
-            key = ws.cell(row=1, column=j+1).value
-            if key is not None:
-                key = key.strip() if isinstance(key,str) else key
-                value = ws.cell(row=i + 1, column=j + 1).value
-                value = value.strip() if isinstance(value,str) else value
-                if isinstance(value, str):
-                    if value == 'True':
-                        value = True
-                    if value == 'False':
-                        value = False
-                rdict[key] = value
-        dd.append(rdict)
-
+    # print(time.time()-t0)
+    # dd = []
+    # key_cols = [j for j in range(ws.max_column) if ws.cell(row=1, column=j + 1).value is not None]
+    # print(time.time()-t0, ws.max_row)
+    # np.array([[i.value for i in j[1:5]] for j in ws.rows])
+
+    import numpy as np
+    A = np.array([[i.value for i in j] for j in ws.rows])
+    # print(time.time() - t0, ws.max_row, len(key_cols))
+
+
+    # for j in range(A.shape[1]):
+
+
+
+
+    a = 234
+
+    # for i in range(1, ws.max_row):
+    #     rdict = {}
+    #     if not any( [ws.cell(row=i+1, column=j+1).value is not None for j in key_cols] ):
+    #         continue
+    #     for j in key_cols:
+    #         key = ws.cell(row=1, column=j+1).value
+    #         if key is not None:
+    #             key = key.strip() if isinstance(key,str) else key
+    #             value = ws.cell(row=i + 1, column=j + 1).value
+    #             value = value.strip() if isinstance(value,str) else value
+    #             if isinstance(value, str):
+    #                 if value == 'True':
+    #                     value = True
+    #                 if value == 'False':
+    #                     value = False
+    #             rdict[key] = value
+    #     dd.append(rdict)
+
+    # print(time.time()-t0)
+
+    A = A[:, A[0] != None]
+    A = A[(A != None).sum(axis=1) > 0, :]
+
+    dd2 = []
+    for i in range(1, A.shape[0]):
+        A[A == 'True'] = True
+        A[A == 'False'] = False
+
+        d = dict(zip(A[0, :].tolist(), [a.strip() if isinstance(a,str) else a for a in A[i, :].tolist() ]))
+        dd2.append(d)
+
+    # print(time.time() - t0)
+    dd = dd2
+    # if dd != dd2:
+    #     for k in range(len(dd)):
+    #         if dd[k] != dd2[k]:
+    #             print(k)
+    #             print(dd)
+    #             print(dd2)
+    #     assert False
+    #     print("BAd!")
     if as_dict_list:
         dl = list_dict2dict_list(dd)
         for k in dl.keys():
@@ -51,6 +94,8 @@ def xlsx_to_dicts(xlsx_file,sheet=None, as_dict_list=False):
             if len(x) == 1: x = x.pop()
             dl[k] = x
         dd = dl
+    wb.close()
+    # print("xlsx2dicts", time.time()-t0)
     return dd
 
 def get_enrolled_students():
@@ -200,6 +245,7 @@ def get_forum(paths):
         d2.append({k: v[i] for k, v in dd.items()})
     return d2
 
+@profile
 def class_information():
     course_number = core_conf['course_number']
     piazza = 'https://piazza.com/dtu.dk/%s%s/%s' % (semester().lower(), year(), course_number)
@@ -214,8 +260,8 @@ def class_information():
          'piazza': piazza, # deprecated.
          'course_number': course_number,
          'semester': semester(),
-         'reports_handout': [1,6],
-         'reports_handin': [6,11],
+         # 'reports_handout': [1,6], # Set in excel conf.
+         # 'reports_handin': [6, 11], # set in excel conf.
          'semester_id': semester_id(),
          'today': today(),
          'instructors': get_instructors(),
diff --git a/src/coursebox/core/projects.py b/src/coursebox/core/projects.py
index 3c017f2..ef3cb07 100644
--- a/src/coursebox/core/projects.py
+++ b/src/coursebox/core/projects.py
@@ -1,3 +1,6 @@
+import re
+import tempfile
+import tika
 import os
 import shutil
 import openpyxl
@@ -5,7 +8,6 @@ import numpy as np
 import itertools
 import math
 import glob
-# import zipfile
 from tika import parser
 from openpyxl.worksheet.datavalidation import DataValidation
 from openpyxl.utils import get_column_letter
@@ -22,6 +24,8 @@ from jinjafy.plot.plot_helpers import get_colors
 import time
 from collections import defaultdict
 import zipfile
+import hashlib
+import pandas as pd
 
 
 def get_dirs(zf):
@@ -32,13 +36,11 @@ def get_dirs(zf):
 
 def fix_handins_fuckup(project_id=2):
     """ Handle the problem with multiple hand-ins in DTU learn. """
-    import zipfile
     paths = get_paths()
     from coursebox.core.info import class_information
     info = class_information()
     zf = paths['instructor_project_evaluations'] + f"/zip{project_id}.zip"
 
-
     tas = [i['shortname'] for i in info['instructors'] ]
     ta_links = {i['shortname']: i for i in info['instructors']}
 
@@ -51,7 +53,6 @@ def fix_handins_fuckup(project_id=2):
             ta_reports[r] = ta
 
     fls = get_dirs(zf)
-
     # fls = [f for f in zip.namelist() if not f.endswith("tml") and f.endswith("/")]
     d = defaultdict(lambda: [])
     for l in fls:
@@ -123,7 +124,6 @@ def handle_projects(verbose=False, gather_main_xlsx_file=True, plagiarism_check=
     zip1 = instructor_path + "/zip1.zip"
     zip2 = instructor_path + "/zip2.zip"
     zip3 = instructor_path + "/zip3.zip"
-
     zips = [None, zip1, zip2, zip3]
 
     for j,zf in enumerate(zips):
@@ -138,12 +138,12 @@ def handle_projects(verbose=False, gather_main_xlsx_file=True, plagiarism_check=
             continue
         else: # instructor files do not exist
             if j == 0:
-                copy_populate_from_template(info, sheet_number=j, zip_file=None)
+                copy_populate_from_template(paths, info, sheet_number=j, zip_file=None)
 
             elif os.path.exists(zf):
                 # make a copy of report template and populate it with groups obtained from previous report evaluation.
                 # all_groups = get_all_reports_from_collected_xlsx_file()
-                copy_populate_from_template(info, sheet_number=j, zip_file=zf)
+                copy_populate_from_template(paths, info, sheet_number=j, zip_file=zf)
                 # distribute_zip_content(info, sheet=j, zf_base=zf)
             else:
                 print("When available, please move downloaded copy of all reports from campusnet to destination:")
@@ -228,13 +228,13 @@ def compute_error_files(info, paths):
                         es = err_label + f"> Report score is {g.get('score', 0)}. The report score has to be between 0 and 4; probably due to a too high value of 'Delta' in instructor sheet."
                         ERRORS[ins].append(es)
 
-                if repn >= 1 and not g['comments']:
+                if repn >= 1 and not g['comments'] and info['course_number'] != '02465':
                     es = err_label + "> Incomplete report evaluation (missing comments field)"
                     es += "Please fill out comments field in your excel sheet."
                     ERRORS[ins].append(es)
 
 
-                if repn >= 1 and not g['approver_comments']:
+                if repn >= 1 and not g['approver_comments']  and info['course_number'] != '02465':
                     es = err_label + "> Incomplete report evaluation (you are missing the approver comments field; can simply be set to 'ok')."
                     ERRORS.get(g['approver'], []).append(es)
 
@@ -300,10 +300,70 @@ def get_instructor_xlsx_files(info, sheet):
     return xlsx
 
 
-import hashlib
+def get_groups_from_learn_xslx_file(paths, sheet_number):
+    fname = f"{paths['instructor_project_evaluations']}/groups{sheet_number}.xlsx"
+    all_groups = []
+    if os.path.exists(fname):
+        # Reading from the groups{number}.xlsx group-id file exported from DTU learn. Note this file contains fuckups.
+        dg = defaultdict(list)
+        df = pd.read_excel(fname)
+        for uname, group_id in zip(df['Username'], df['Project groups']):
+            id = int(group_id.split(" ")[1])
+            if len(uname) == 7 and uname[0] == 's':
+                dg[id].append(uname)
+            else:
+                dg[id].append("DTU-LEARN-FUCKED-THIS-ID-UP-CHECK-ON-REPORT")
+
+        all_groups = [{'group_id': id, 'student_ids': students} for id, students in dg.items()]
+    return all_groups
+
+def search_projects(paths, sheet_number, patterns):
+    zip_files = [paths['instructor_project_evaluations'] + "/zip%d.zip" % sheet_number]
+    # print(zip_files)
+
+    all_groups = []
+    gps = defaultdict(list)
+    for zip_file in zip_files:
+        if os.path.exists(zip_file):
+            tmpdir = tempfile.TemporaryDirectory()
+            zipfile.ZipFile(zip_file).extractall(path=tmpdir.name)
+            pdfs = glob.glob(tmpdir.name + "/**/*.pdf", recursive=True)
+            for pdf in pdfs:
+                pdf_parsed = tika.parser.from_file(pdf)
+                id =int(os.path.dirname(pdf).split(" - ")[1].split(" ")[1])
+                students = re.findall('s\d\d\d\d\d\d', pdf_parsed['content'], flags=re.IGNORECASE)
+                gps[id] += students
+
+    for id, students in gps.items():
+        all_groups.append({'group_id': id, 'student_ids': list(set(students))})
+    return all_groups
+
+
+def unpack_zip_file_recursively(zip_file, destination_dir):
+    """
+    Unpack the zip_file (extension: .zip) to the given directory.
+
+    If the folders in the zip file contains other zip/files, these are unpacked recursively.
+    """
+    # Unpack zip file recursively and flatten it.
+    zipfile.ZipFile(zip_file).extractall(path=destination_dir)
+    ls = glob.glob(destination_dir + "/*")
+    for f in ls:
+        if os.path.isdir(f):
+            zipfiles = glob.glob(f + "/*.zip")
+            for zp in zipfiles:
+                print("Unpacking student zip file>", zp)
+                zipfile.ZipFile(zp).extractall(path=os.path.dirname(zp) + "/")
+
+
+def copy_populate_from_template(paths, info, sheet_number,zip_file):
+    # Try to load group ids from the project pdf's
+    all_groups = search_projects(paths, sheet_number, r"s\d{6}")
+    # all_groups = get_groups_from_learn_xslx_file(paths, sheet_number)
+    if len(all_groups) == 0:
+        all_groups = projects_info.get_groups_from_report(repn=sheet_number-1) if sheet_number > 0 else []
+    # Hopefully this did the trick and we have the groups all grouped up.
 
-def copy_populate_from_template(info, sheet_number,zip_file):
-    all_groups = projects_info.get_groups_from_report(repn=sheet_number-1) if sheet_number > 0 else []
     # set up which TA approve which TA
     if any( [i['language'] not in ["en", "any"] for i in info['instructors'] ]):
         print(info['instructors'])
@@ -337,10 +397,13 @@ def copy_populate_from_template(info, sheet_number,zip_file):
     n_groups_per_instructor = 24 + (sheet_number == 0) * 26
 
     if sheet_number > 0:
-        zfd = zip_file[:-4]
-        if not os.path.exists(zfd):
-            os.mkdir(zfd)
-        zipfile.ZipFile(zip_file).extractall(path=zfd)
+        # zfd = zip_file[:-4]
+        # if not os.path.exists(zfd):
+        #     os.mkdir(zfd)
+        zfd = tempfile.TemporaryDirectory().name
+        # zipfile.ZipFile(zip_file).extractall(path=tmpdir.name)
+
+        unpack_zip_file_recursively(zip_file, destination_dir=zfd)
         # get all report handins (i.e. directories)
         ls = [l for l in glob.glob(zfd + "/*") if l[-3:] not in ["txt", "tml"]]
 
@@ -431,8 +494,8 @@ def copy_populate_from_template(info, sheet_number,zip_file):
         corrector = all_tas[shortname]['approver']
         if sheet_number > 0:
             # Copy reports to directory (distribute amongst TAs)
-            b_dir = os.path.dirname(zip_file)
-            ins_dir = "%s/project_%i_%s/"%(b_dir, sheet_number, shortname)
+            # b_dir = os.path.dirname(zip_file)
+            ins_dir = "%s/project_%i_%s/"%(zfd, sheet_number, shortname)
 
             if not os.path.exists(ins_dir):
                 os.mkdir(ins_dir)
@@ -440,7 +503,7 @@ def copy_populate_from_template(info, sheet_number,zip_file):
             for handin in all_tas[shortname]['handins']:
                 shutil.move(handin['path'], ins_dir)
 
-            shutil.make_archive(ins_dir[:-1], 'zip', ins_dir)
+            shutil.make_archive(os.path.dirname(zip_file) +"/"+ os.path.basename(ins_dir[:-1]), 'zip', ins_dir)
             time.sleep(2)
             print("Removing tree of reports to clear up space...")
             shutil.rmtree(ins_dir)
@@ -471,10 +534,10 @@ def copy_populate_from_template(info, sheet_number,zip_file):
                         sheet.cell(STUDENT_ID_ROW+j, ccol+i).value = s
         wb.save(ifile)
         wb.close()
-    # clean up zip file directories
-    if sheet_number > 0:
-        zfd = zip_file[:-4]
-        shutil.rmtree(zfd)
+    # clean up zip file directories; since it is a tmp file, we don't have to.
+    # if sheet_number > 0:
+    #     zfd = zip_file[:-4]
+    #     shutil.rmtree(zfd)
 
 def write_dropdown_sumprod_sheet(sheet):
     ccol = 2
diff --git a/src/coursebox/core/projects_info.py b/src/coursebox/core/projects_info.py
index 0b24331..62e1457 100644
--- a/src/coursebox/core/projects_info.py
+++ b/src/coursebox/core/projects_info.py
@@ -3,6 +3,7 @@ import os
 import re
 import openpyxl
 import numpy as np
+from line_profiler_pycharm import profile
 
 INSTRUCTOR_ROW = 6
 INSTRUCTOR_CHECKER_ROW = 31
@@ -16,19 +17,6 @@ RANGE_MIN_COL = 5
 
 DELTA_ALLOWED_ROW = 111 # The range of possible delta-values. Should be in an empty (new) row at bottom.
 
-def get_all_reports_from_collected_xlsx_file_DEFUNCT():  # when is this used?
-    out = get_output_file()
-    wb = openpyxl.load_workbook(out)
-    all_reports = {}
-    for repn in range(3, -1, -1):
-        cls = []
-        for i in range(2, wb.worksheets[repn].max_column + 1):
-            cp = parse_column(wb.worksheets[repn], report_number=repn, column=i)
-            if not cp['student_ids']:
-                continue
-            cls.append(cp)
-        all_reports[repn] = cls
-    return all_reports
 
 def parse_column_student_ids(v):
     sn = []
@@ -42,7 +30,82 @@ def parse_column_student_ids(v):
             sn.append(g)
     return sn
 
+
+def parse_column_numpy(col, report_number, column):
+    """ Parse a column assuming it is defined as a numpy array.
+    This is the recommended method as it is much, much faster.
+    """
+    # ws = worksheet  # wb.worksheets[sheet]
+    sn = []
+    group_id = col[STUDENT_ID_ROW - 1-1] #).value
+
+    # col = ['' if col[0] is np.NAN else x for x in col]
+
+    for i in range(0, 3):
+        v = col[i + STUDENT_ID_ROW-1]#, column=column).value
+        sn += parse_column_student_ids(v)
+
+
+    instructor = col[INSTRUCTOR_ROW-1]#, column=column).value
+    approver = col[INSTRUCTOR_ROW+1-1]# , column=column).value
+
+    if instructor:
+        instructor = instructor.lower()
+    if approver:
+        approver = str(approver).lower()
+
+    content = None
+    comments = None
+    appr_comments = None
+    if report_number > 0 and sn:
+        N = 38
+        rarr = np.ndarray(shape=(N,1),dtype=np.object)
+        for j in range(N):
+
+            v = col[3 + STUDENT_ID_ROW+j-1]#, column=column).value
+            rarr[j,0] = v
+        content = rarr
+        comments = col[EVALUATION_ROW_END+5-1]# , column=column).value
+        appr_comments = col[EVALUATION_ROW_END+6-1]# , column=column).value
+
+    cgroup = {'column_j': column, 'student_ids': sn, 'instructor': instructor, "approver": approver, 'content': content,
+              "comments": comments, "approver_comments": appr_comments, 'missing_fields': [],
+              'group_id': group_id}
+
+    # Now, find errors... This involves first finding non-zero columns
+    if report_number > 0 and sn:
+        score = cgroup['content'][-3, 0]
+        cgroup['score'] = score
+        cgroup['pct'] = score2pct(score)
+
+        # if report_number == 3: # this obviously needs fixing for next semester.
+        #     raise Exception("No report number 3 anymore. ")
+        #     I = []
+        #     for i in range(42): # max number of evaluation fields (irrelevant)
+        #         v1 = col[WEIGHT_ROW_START+i-1, RANGE_MIN_COL-1]# ).value
+        #         v2 = col[WEIGHT_ROW_START+i-1, RANGE_MIN_COL+1-1]#).value
+        #         if (v1 == -1 and v2 == 1) or (v1 == 0 and v2 == 4):
+        #             I.append(i)
+        #         if v1 == -1 and v2 == 1:
+        #             # print("delta col")
+        #             break
+        #
+        #     for i in I:
+        #         w1 = worksheet.cell(row=WEIGHT_ROW_START + i, column=1).value
+        #         w3_ = worksheet.cell(row=INSTRUCTOR_ROW + i+2, column=1).value # should agree with label in w1
+        #         w2 = worksheet.cell(row=INSTRUCTOR_ROW + i+2, column=column).value
+        #         if w2 == None:
+        #             cgroup['missing_fields'].append( (i, w1) )
+        #             if report_number < 3:
+        #                 print("old report nr.")
+
+    return cgroup
+
+
+
 def parse_column(worksheet, report_number, column):
+    """ This is the old method. It is very slow. Use the numpy-version above.
+    """
     ws = worksheet  # wb.worksheets[sheet]
     sn = []
     group_id = ws.cell(row=STUDENT_ID_ROW - 1, column=column).value
@@ -54,7 +117,8 @@ def parse_column(worksheet, report_number, column):
     instructor = ws.cell(row=INSTRUCTOR_ROW, column=column).value
     approver = ws.cell(row=INSTRUCTOR_ROW+1, column=column).value
 
-    if instructor: instructor = instructor.lower()
+    if instructor:
+        instructor = instructor.lower()
     if approver:
         approver = str(approver).lower()
 
@@ -135,32 +199,47 @@ def get_groups_from_report(repn):
         cls.append(cp)
     return cls
 
+
+# @profile
 def populate_student_report_results(students):
     # take students (list-of-dicts in the info format) and assign them the results from the reports.
     out = get_output_file()
+    import time
+    t0 = time.time()
     print("> Loading student report scores from: %s"%out)
     if not os.path.exists(out):
         return students, []
 
     for k in students:
         students[k]['reports'] = {i: None for i in range(4)}
+    import pandas as pd
 
-    wb = openpyxl.load_workbook(out,data_only=True)
+    wb = openpyxl.load_workbook(out, data_only=True, read_only=True)
     # Perhaps find non-empty cols (i.e. those with content)
-
+    print("> time elapsed", time.time() - t0)
     maximal_groups = []
     maximal_groups_students = []
 
     for repn in range(3, -1, -1):
         cls = []
-        for i in range(2, wb.worksheets[repn].max_column + 1):
-            cp = parse_column(wb.worksheets[repn], report_number=repn, column=i)
+        sheet = pd.read_excel(out, sheet_name=repn, index_col=None, header=None)
+        sheet = sheet.fillna('')
+        sheet = sheet.to_numpy()
+        # to_numpy()
+        for i in range(1,sheet.shape[1]):
+
+            # for i in range(2, wb.worksheets[repn].max_column + 1):
+            # print(i, wb.worksheets[repn].max_column)
+            # s = pd.read_excel(out, sheet_name=1)
+            cp = parse_column_numpy(sheet[:,i], report_number=repn, column=i)
+
+
+            # cp = parse_column(wb.worksheets[repn], report_number=repn, column=i)
             if not cp['student_ids']:
-                continue
+                break
             cls.append(cp)
 
         for g in cls:
-
             for sid in g['student_ids']:
                 student = students.get(sid, None)
                 if student is None:
@@ -172,5 +251,5 @@ def populate_student_report_results(students):
                     if sid not in maximal_groups_students:
                         maximal_groups.append(g)
                         maximal_groups_students += g['student_ids']
-
+    print("> time elapsed", time.time() -t0)
     return students, maximal_groups
\ No newline at end of file
diff --git a/src/coursebox/material/__pycache__/homepage_lectures_exercises.cpython-38.pyc b/src/coursebox/material/__pycache__/homepage_lectures_exercises.cpython-38.pyc
index fcdc53b8ce3e88021bd3cba3dda0c41923e0c4a1..6ad3c21eb6a79dbef53f5e24270dca2a8f885fc2 100644
GIT binary patch
delta 5770
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

delta 4831
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
UHQ|9^C>RJo6V$_v!9eBzKM`S5I{*Lx

diff --git a/src/coursebox/material/homepage_lectures_exercises.py b/src/coursebox/material/homepage_lectures_exercises.py
index f3f71f2..0a6b395 100644
--- a/src/coursebox/material/homepage_lectures_exercises.py
+++ b/src/coursebox/material/homepage_lectures_exercises.py
@@ -3,6 +3,9 @@ import shutil, os, glob
 from datetime import datetime, timedelta
 import calendar
 import pickle
+import time
+from line_profiler_pycharm import profile
+from coursebox.thtools_base import partition_list
 
 import slider
 from jinjafy import jinjafy_comment
@@ -16,6 +19,7 @@ from coursebox.core.info import class_information
 from coursebox.material.lecture_questions import lecture_question_compiler
 from slider import latexmk
 import coursebox
+# from line_profiler_pycharm import profile
 
 def get_feedback_groups():
     paths = get_paths()
@@ -47,12 +51,13 @@ def get_feedback_groups():
     reduced_groups = [rg for rg in reduced_groups if len(rg)>0]
     # groups are now partitioned.
     if len(remaining_lectures) > 0:
-        fbgs = coursebox.thtools_base.partition_list(reduced_groups, len(remaining_lectures))
+        fbgs = partition_list(reduced_groups, len(remaining_lectures))
         for gg in fbgs:
             for g in gg:
                 already_used = already_used + g
 
-        lst = thtools.thtools_base.partition_list([s for s in all_students if s not in already_used], len(remaining_lectures))
+
+        lst = partition_list([s for s in all_students if s not in already_used], len(remaining_lectures))
         for i in range(len(remaining_lectures)):
             dg = []
             for g in fbgs[i]: dg += g  # flatten the list
@@ -217,7 +222,49 @@ def compile_simple_files(paths, info, template_file_list, verbose=False):
         jinjafy_template(data=d2, file_in=fname, file_out=tex_out, filters=get_filters(), template_searchpath=paths['instructor'])
         latexmk(tex_out, pdf_out= paths['pdf_out'] + "/" + os.path.basename(tex_out)[:-4]+".pdf")
 
-def fix_shared(paths, output_dir, pdf2png=False,dosvg=True,verbose=False, compile_templates=True):
+# rec_fix_shared(shared_base=paths['shared'], output_dir=output_dir)
+import time
+# import dirsync
+# dirsync.sync(paths['shared'], output_dir, 'diff')
+
+
+# Do smarter fixin'
+from pathlib import Path
+
+from jinjafy.cache.simplecache import hash_file_
+
+@profile
+def get_hash_from_base(base):
+    if not os.path.exists(base + "/sharedcache.pkl"):
+        source = {}
+    else:
+        with open(base + "/sharedcache.pkl", 'rb') as f:
+            source = pickle.load(f)
+
+    actual_files = {}
+    for f in glob.glob(base + "/**", recursive=True):
+        if os.path.isdir(f):
+            continue
+        if f.endswith("sharedcache.pkl"):
+            continue
+        rel = os.path.relpath(f, base)
+
+        # d = dict(mtime=os.path.getmtime(f))
+        actual_files[rel] = dict(mtime=os.path.getmtime(f), hash=-1, modified=False)
+
+        if rel not in source or (actual_files[rel]['mtime'] != source[rel].get('mtime', -1)): # It has been modified, update hash
+            # print(rel, time.ctime(actual_files[rel]['mtime']), time.ctime(source[rel].get('mtime', -1)))
+            new_hash = hash_file_(f)
+            # actual_files[rel] = {}
+            actual_files[rel]['modified'] = new_hash != source.get(rel, {}).get('hash', -1)
+            actual_files[rel]['hash'] = new_hash
+        else:
+            actual_files[rel]['hash'] = source[rel]['hash']
+    return actual_files
+
+
+@profile
+def fix_shared(paths, output_dir, pdf2png=False,dosvg=True,verbose=False, compile_templates=True,shallow=True):
     '''
     Copy shared files into lecture directories
     '''
@@ -225,46 +272,171 @@ def fix_shared(paths, output_dir, pdf2png=False,dosvg=True,verbose=False, compil
     from jinjafy.cache import cache_contains_file, cache_update_file
     from slider.convert import svg2pdf, pdfcrop
     from slider import convert
+    import filecmp
 
-    def rec_fix_shared(shared_base, output_dir):
-        if dosvg:
-            for svg in glob.glob(shared_base+"/*.svg"):
-                if not cache_contains_file(cache_base, svg):
-                    if verbose:
-                        print("converting to pdf", svg)
-                    svg2pdf(svg,crop=True, text_to_path=True)
-                    cache_update_file(cache_base, svg)
-        files = glob.glob(shared_base+"/*")
-        for f in files:
-            if f.endswith("cache.pkl"):
-                continue
-            # check if template
-            if "templates" in f and f.endswith("_partial.tex"):
-                continue
+    t0 = time.time()
+    shared_base = paths['shared']
+    output_dir = output_dir
 
-            if os.path.isdir(f):
-                od2 = output_dir + "/" + os.path.basename(f)
-                if not os.path.exists(od2):
-                    os.mkdir(od2)
-                rec_fix_shared(f, od2)
-            else:
-                of = output_dir + "/" + os.path.basename(f)
-                if not cache_contains_file(cache_base, f) or not os.path.exists(of):
-                    print(f"> {f} -> {of}")
-                    shutil.copy(f, of)
-                    if f.endswith(".pdf") and pdf2png:
+    import glob
+    # def get_cache_from_dir(shared_base):
+    # print("Beginning file cache..")
+
+
+    source = get_hash_from_base(shared_base)
+    target = get_hash_from_base(output_dir)
 
-                        if verbose:
-                            print(" converting to png", f)
-                        convert.pdf2png(of)
-                    cache_update_file(cache_base, f)
+    # update_source_cache = False
+    source_extra = {}
+    for rel in source:
+        if rel.endswith(".svg") and source[rel]['modified']:
+            pdf_file = svg2pdf(shared_base + "/"+rel, crop=True, text_to_path=True, verbose=True)
+            rel = os.path.relpath(pdf_file, shared_base)
+            source_extra[rel] = dict(mtime=os.path.getmtime(pdf_file), hash=hash_file_(pdf_file), modified=True)
 
-            if verbose:
-                print(" done!")
+    for k, v in source_extra.items():
+        source[k] = v
 
 
+            # update_source_cache = True
+    # Perform sync here.
+    for rel in source:
+        if rel.endswith("_partial.tex"):
+            continue
+
+        if rel not in target or target[rel]['hash'] != source[rel]['hash']:
+            print(" -> ", output_dir + "/" + rel)
+            shutil.copy(shared_base +"/" + rel, output_dir + "/" + rel)
+            target[rel] = source[rel].copy()
+            target[rel]['modified'] = True
+            target[rel]['mtime'] = os.path.getmtime(output_dir + "/" + rel)
+
+    if pdf2png:
+        for rel in target:
+            if rel.endswith(".pdf") and target[rel]['modified']:
+                # print("pdf2png: ")
+                png = convert.pdf2png(output_dir + "/" + rel, verbose=True)
+                target[rel]['modified'] = False
+                target[rel]['hash'] = hash_file_(output_dir + "/" + rel)
+                target[rel]['mtime'] = os.path.getmtime(output_dir + "/" + rel)
+
+    # Save the cache.
+
+    with open(shared_base + "/sharedcache.pkl", 'wb') as f:
+        pickle.dump(source, f)
+
+    with open(output_dir + "/sharedcache.pkl", 'wb') as f:
+        pickle.dump(target, f)
+
+    print("fix_shared()", time.time() - t0)
+
+    #
+    # if pdf2png:
+    #     if f.endswith(".pdf") and pdf2png:
+    #         if verbose:
+    #             print("converting to png", f)
+    #         convert.pdf2png(of)
+    #
+    #     for f in source:
+    #         if f not in target:
+    #             print(f)
+    #         else:
+    #             if source[f]['hash'] != target[f]['hash']:
+    #                 print(f, f)
+    #
+    #
+    #
+    # a = 234
+    # # if rel not in source:
+    #
+    #     #     source[rel] = dict(mtime=os.path.getmtime(f), hash=hash_file_(f))
+    #     #
+    #
+    #
+    # # Everything has a hash/mtime that is up to date. Now look at target dir
+    #
+    # get_cache_from_dir(output_dir)
+    #
+    # # Get the corresponding output at destination:
+    #
+    #
+    #
+    #
+    #
+    #
+    # for path in Path(shared_base).rglob('*'):
+    #     print(path)
+    # a = 234
+    # def rec_fix_shared(shared_base, output_dir):
+    #     if dosvg:
+    #         for svg in glob.glob(shared_base+"/*.svg"):
+    #             # if not os.path.exists(shared_base + )
+    #             if not cache_contains_file(cache_base, svg):
+    #                 # if verbose:
+    #                 print("converting to pdf", svg)
+    #                 svg2pdf(svg,crop=True, text_to_path=True)
+    #                 cache_update_file(cache_base, svg)
+    #                 assert False
+    #
+    #     files = glob.glob(shared_base+"/*")
+    #     for f in files:
+    #         if f.endswith("cache.pkl"):
+    #             continue
+    #
+    #         if "templates" in f and f.endswith("_partial.tex"):
+    #             continue
+    #
+    #         if os.path.isdir(f):
+    #             od2 = output_dir + "/" + os.path.basename(f)
+    #             if not os.path.exists(od2):
+    #                 os.mkdir(od2)
+    #             rec_fix_shared(f, od2)
+    #         else:
+    #             of = output_dir + "/" + os.path.basename(f)
+    #             if not os.path.exists(of) or not filecmp.cmp(f, of,shallow=shallow):
+    #                 print(f"> fix_shared() -> {of}")
+    #                 shutil.copy(f, of)
+    #                 if f.endswith(".pdf") and pdf2png:
+    #                     if verbose:
+    #                         print("converting to png", f)
+    #                     convert.pdf2png(of)
+    #                 # cache_update_file(cache_base, f)
+    #
+    #         if verbose:
+    #             print(" done!")
+
+    # if pdf2png:
+    #     assert False
+
+
+
+    # get diff.
+
+    # directory_cmp = filecmp.dircmp(a=paths['shared'], b=output_dir)
+    # from filecmp import dircmp
+    # from filecmp import dircmp
+    # def print_diff_files(dcmp):
+    #     for name in dcmp.diff_files:
+    #         print("diff_file %s found in %s and %s" % (name, dcmp.left, dcmp.right))
+    #         print("")
+    #     for sub_dcmp in dcmp.subdirs.values():
+    #         print_diff_files(sub_dcmp)
+    #
+
+    # t0 = time.time()
+    # dcmp = dircmp(paths['shared'], output_dir)
+    # print_diff_files(dcmp)
+    # print("dircmp", time.time() - t0)
+    # directory_cmp.report()
+    # import time
+    # t0 = time.time()
+    # rec_fix_shared(shared_base=paths['shared'], output_dir=output_dir)
+    # import time
+    # # import dirsync
+    # # dirsync.sync(paths['shared'], output_dir, 'diff')
+    # print("mine", time.time() - t0)
+    a = 234
 
-    rec_fix_shared(shared_base=paths['shared'], output_dir=output_dir)
 
 def jinjafy_shared_templates_dir(paths, info):
     tpd = paths['shared'] + "/templates"
@@ -379,6 +551,7 @@ def mvfiles(source_dir, dest_dir):
         if (os.path.isfile(full_file_name)):
             shutil.copy(full_file_name, os.path.dirname(dest_dir))
 
+@profile
 def make_webpage(dosvg=True):
     cinfo = class_information()
     paths = get_paths()
-- 
GitLab