1387 lines
50 KiB
Python
1387 lines
50 KiB
Python
# Local data, saving and manipulating
|
|
import util
|
|
import os, re, gzip, codecs, funcy, pytz, json, random, functools, requests, sys, csv, time, psycopg2
|
|
import hashlib
|
|
import pandas as pd
|
|
import numpy as np
|
|
from collections import defaultdict
|
|
from datetime import datetime as dt
|
|
from datetime import timedelta
|
|
from dateutil.parser import parse
|
|
from os.path import exists, getmtime
|
|
from tabulate import tabulate
|
|
|
|
from semesters import short_to_sis
|
|
from canvas_secrets import postgres_database, postgres_password, postgres_port, postgres_user, postgres_host
|
|
|
|
#########
|
|
######### LOCAL DB
|
|
#########
|
|
|
|
CON = ''
|
|
CURSOR = ''
|
|
|
|
def db():
|
|
global CON,CURSOR
|
|
CON = psycopg2.connect(database=postgres_database,
|
|
host=postgres_host,
|
|
user=postgres_user,
|
|
password=postgres_password,
|
|
port=postgres_port)
|
|
|
|
CURSOR = CON.cursor()
|
|
return CON,CURSOR
|
|
|
|
|
|
'''
|
|
# Help the next function to upload new users directly to conf database on gavilan.
|
|
def employees_refresh_flex(data):
|
|
try:
|
|
data['a'] = 'set/newuser'
|
|
data['sis_user_id'] = data['sis_user_id'][3:]
|
|
print("\nUploading this: \n")
|
|
print(json.dumps(data, indent=2))
|
|
print("\n")
|
|
a = input("Continue (y) or skip (n) ? ")
|
|
if a == 'y':
|
|
# This is what I was missing..........
|
|
# req.add_header("Content-type", "application/x-www-form-urlencoded")
|
|
r3 = requests.post('https://www.gavilan.edu/staff/flex/2020/api.php', params=data)
|
|
print(r3.text)
|
|
#print(r3.headers)
|
|
except Exception as ex:
|
|
print("Failed on: %s\nErr: %s" % (str(data),str(ex)))
|
|
|
|
|
|
|
|
# Everyone in iLearn DB with an xyz@gavilan.edu email address.
|
|
def all_gav_employees():
|
|
(connection,cursor) = db()
|
|
connection.row_factory = dict_factory
|
|
q = """SELECT u.canvasid, u.name, u.created, u.sortablename, h.address, h.type, h.workflow_state,
|
|
h.updated_at, p.last_request_at, p.last_login_at, p.current_login_at, p.last_login_ip,
|
|
p.current_login_ip, p.sis_user_id, p.unique_name FROM users AS u
|
|
JOIN comm_channel AS h ON u.id=h.user_id
|
|
JOIN pseudonyms AS p ON p.user_id=u.id
|
|
WHERE h.address LIKE "%@gavilan.edu"
|
|
ORDER BY u.sortablename"""
|
|
cursor = connection.cursor()
|
|
cursor.execute(q)
|
|
everyone = cursor.fetchall()
|
|
everyone_set = set()
|
|
for E in everyone:
|
|
try:
|
|
everyone_set.add( E['address'].lower() )
|
|
except Exception as e:
|
|
print("Exception: %s\nwith: %s" % (str(e), str(E)))
|
|
|
|
oo = open('cache/temp1.txt','w')
|
|
oo.write(json.dumps(list(everyone_set), indent=2))
|
|
existing = requests.get('https://gavilan.edu/staff/flex/2020/api.php?a=get/users')
|
|
ex = json.loads( existing.text )
|
|
already_enrolled = set()
|
|
for usr in ex['users']:
|
|
try:
|
|
#already_enrolled.add( (usr['goo'], usr['email'].lower(), usr['name']) )
|
|
already_enrolled.add( usr['email'].lower() )
|
|
except Exception as e:
|
|
print("Exception: %s\nWith: %s" % (str(e),str(usr)))
|
|
|
|
oo.write( "\n"*20 + '------------------------------------------\n'*20 + '------ - - - - - - ' )
|
|
oo.write(json.dumps(list(already_enrolled), indent=2))
|
|
|
|
# conf_users wants: goo, email, name, active
|
|
# and emails have random capitalization
|
|
# name is First Last, and sometimes with Middle in there.
|
|
#
|
|
|
|
# using sets: to_enroll = [ x for x in students if x not in already_enrolled ]
|
|
new_emp = [ x for x in everyone_set if x not in already_enrolled ]
|
|
|
|
# take the all_employee list, filter -> anyone who's in 'existing' is removed
|
|
|
|
# funcy.where( lambda x: x['email'] == ae[4] , existing )
|
|
|
|
#new_emp = list(funcy.filter( lambda ae: funcy.where( existing, email=ae['email'] ), all_emp ))
|
|
#new_emp = list(funcy.where( existing, email=b'phowell@gavilan.edu')) #ae['email'] ))
|
|
print(new_emp)
|
|
oo.write( "\n"*20 + '------------------------------------------\n'*20 + '------ - - - - - - ' )
|
|
oo.write(json.dumps(list(new_emp), indent=2))
|
|
|
|
# Now, iLearn db (everyone)... find the rows that match the email addresses
|
|
# that we've decided we need to add (new_emp)
|
|
|
|
#print(everyone)
|
|
#print( "searching for %s" % j )
|
|
#print( "searched for %s, found: %s" % (j, str(to_add) ))
|
|
#print("\nUploading...\n")
|
|
for j in new_emp:
|
|
#j = new_emp[0]
|
|
print(j)
|
|
to_add = list(funcy.where( everyone, address=j ))
|
|
if to_add:
|
|
employees_refresh_flex(to_add[0])
|
|
else:
|
|
print("Didn't find an entry for that account.")
|
|
print("done uploading")
|
|
|
|
'''
|
|
|
|
def user_from_goo(goo):
|
|
if not goo.lower().startswith("g00"):
|
|
goo = "G00" + goo
|
|
q = f"SELECT * FROM canvas.pseudonyms p JOIN canvas.users u ON p.user_id=u.id WHERE p.sis_user_id='{goo}';"
|
|
(connection,cursor) = db()
|
|
cursor.execute(q, None) # execute query with optional parameters
|
|
row = cursor.fetchone() # fetch a single row
|
|
if row:
|
|
# convert row to dict using column names as keys
|
|
return dict(zip([desc[0] for desc in cursor.description], row))
|
|
|
|
def course_sched_entry_from_id(id):
|
|
q = f"SELECT * FROM canvas.schedule s WHERE s.canvascourse={id}"
|
|
(connection,cursor) = db()
|
|
cursor.execute(q, None) # execute query with optional parameters
|
|
row = cursor.fetchone() # fetch a single row
|
|
if row:
|
|
# convert row to dict using column names as keys
|
|
return dict(zip([desc[0] for desc in cursor.description], row))
|
|
|
|
def course_from_id(id):
|
|
q = f"SELECT * FROM canvas.courses c WHERE c.id={id}"
|
|
(connection,cursor) = db()
|
|
cursor.execute(q, None) # execute query with optional parameters
|
|
row = cursor.fetchone() # fetch a single row
|
|
if row:
|
|
# convert row to dict using column names as keys
|
|
return dict(zip([desc[0] for desc in cursor.description], row))
|
|
|
|
def teachers_by_term(TERM = "202430"):
|
|
q = f"""SELECT c.id, c.name, c.course_code, c.sis_source_id, c.created_at, c.start_at, c.workflow_state, e.last_attended_at,
|
|
u.id, u.sortable_name, u.created_at FROM canvas.courses AS c
|
|
JOIN canvas.enrollments AS e ON e.course_id=c.id
|
|
JOIN canvas.users AS u ON u.id=e.user_id
|
|
WHERE c.sis_source_id LIKE '{TERM}%' AND e.type='TeacherEnrollment' ORDER BY u.sortable_name, c.course_code;"""
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
all_teachers = cursor.fetchall()
|
|
|
|
table = [ [t[9],t[1],t[3],t[6]] for t in all_teachers]
|
|
print(tabulate(table))
|
|
|
|
#for t in all_teachers:
|
|
# print("\t".join( [str(x) for x in [t[9],t[1],t[3],t[6]]]))
|
|
return all_teachers
|
|
|
|
|
|
|
|
def courses_in_term(TERM = "202430"):
|
|
q = f"""SELECT c.id, c.name, c.course_code, c.sis_source_id, c.workflow_state FROM canvas.courses AS c
|
|
WHERE c.sis_source_id LIKE '{TERM}%' ORDER BY c.course_code;"""
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
all = cursor.fetchall()
|
|
|
|
#table = [ [t[9],t[1],t[3],t[6]] for t in all_teachers]
|
|
print(tabulate(all))
|
|
|
|
|
|
|
|
def pages_in_term(TERM="202430"): #
|
|
q = f"""SELECT c.id, c.course_code, c.sis_source_id, wp.id as wp_id, wp.title, wp.url, c.name , wp.body
|
|
FROM canvas.courses c
|
|
JOIN canvas.wiki_pages wp ON wp.context_id=c.id
|
|
WHERE c.sis_source_id LIKE '{TERM}%'
|
|
ORDER BY c.sis_source_id, wp.title;"""
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
all = cursor.fetchall()
|
|
#print(tabulate(all))
|
|
return all
|
|
|
|
|
|
# Fetch orientation shell course ids keyed by year from the local Canvas database.
|
|
def get_orientation_shells(years=None):
|
|
year_filter = set(str(y) for y in years) if years else None
|
|
(connection, _cursor) = db()
|
|
cursor = connection.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT id,
|
|
name,
|
|
substring(name FROM '(\\d{4})') AS year
|
|
FROM canvas.courses
|
|
WHERE name ILIKE 'iLearn Student Orientation %'
|
|
"""
|
|
)
|
|
shells = {}
|
|
for course_id, name, year in cursor.fetchall():
|
|
if not year:
|
|
continue
|
|
if year_filter and year not in year_filter:
|
|
continue
|
|
if year not in shells:
|
|
shells[year] = {'id': course_id, 'name': name}
|
|
return shells
|
|
|
|
|
|
# Collect per-student orientation membership details keyed by Canvas user id.
|
|
def get_orientation_memberships(years=None):
|
|
year_filter = set(str(y) for y in years) if years else None
|
|
(connection, _cursor) = db()
|
|
cursor = connection.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT e.user_id,
|
|
substring(c.name FROM '(\\d{4})') AS year
|
|
FROM canvas.enrollments e
|
|
JOIN canvas.courses c ON c.id = e.course_id
|
|
WHERE c.name ILIKE 'iLearn Student Orientation %'
|
|
AND e.type = 'StudentEnrollment'
|
|
AND e.workflow_state IN ('active', 'completed', 'inactive')
|
|
"""
|
|
)
|
|
memberships = {}
|
|
for user_id, year in cursor.fetchall():
|
|
if not year:
|
|
continue
|
|
if year_filter and year not in year_filter:
|
|
continue
|
|
user_key = str(user_id)
|
|
membership = memberships.setdefault(user_key, {'years': set(), 'total': 0})
|
|
if year not in membership['years']:
|
|
membership['years'].add(year)
|
|
membership['total'] += 1
|
|
return memberships
|
|
|
|
|
|
# Produce student enrollment counts for the orientation audit.
|
|
def get_student_enrollment_summary():
|
|
(connection, _cursor) = db()
|
|
cursor = connection.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT u.id,
|
|
u.sortable_name,
|
|
u.name,
|
|
sis_map.sis_user_id,
|
|
COALESCE(SUM(CASE WHEN e.type = 'StudentEnrollment' THEN 1 ELSE 0 END), 0) AS student_enrollments,
|
|
COALESCE(SUM(CASE WHEN e.type = 'TeacherEnrollment' THEN 1 ELSE 0 END), 0) AS teacher_enrollments
|
|
FROM canvas.users u
|
|
LEFT JOIN canvas.enrollments e
|
|
ON e.user_id = u.id
|
|
AND e.workflow_state IN ('active', 'completed', 'inactive')
|
|
LEFT JOIN LATERAL (
|
|
SELECT p.sis_user_id
|
|
FROM canvas.pseudonyms p
|
|
WHERE p.user_id = u.id
|
|
AND p.workflow_state = 'active'
|
|
AND p.sis_user_id IS NOT NULL
|
|
AND p.sis_user_id <> ''
|
|
ORDER BY p.position NULLS FIRST, p.id
|
|
LIMIT 1
|
|
) sis_map ON TRUE
|
|
GROUP BY u.id, u.sortable_name, u.name, sis_map.sis_user_id
|
|
HAVING COALESCE(SUM(CASE WHEN e.type = 'StudentEnrollment' THEN 1 ELSE 0 END), 0) > 0
|
|
"""
|
|
)
|
|
results = []
|
|
for row in cursor.fetchall():
|
|
user_id, sortable_name, name, sis_user_id, student_count, teacher_count = row
|
|
results.append(
|
|
{
|
|
'user_id': user_id,
|
|
'sortable_name': sortable_name,
|
|
'name': name,
|
|
'sis_user_id': sis_user_id,
|
|
'student_enrollments': int(student_count or 0),
|
|
'teacher_enrollments': int(teacher_count or 0),
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
def user_ids_in_shell(shellid):
|
|
q = f"""select e.user_id from canvas.enrollments e
|
|
where e.course_id = {shellid} and e.type='StudentEnrollment' and e.workflow_state='active';"""
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
students = {str(row[0]) for row in cursor.fetchall()}
|
|
print(f"{len(students)} students currently in shell id {shellid}.")
|
|
return students
|
|
|
|
|
|
def users_new_this_semester(sem=''):
|
|
if not len(sem):
|
|
sem = input("which semester? (ex: 202150) ")
|
|
users_to_enroll = set()
|
|
|
|
where1 = "c.sis_source_id LIKE '%s-%%'" % sem
|
|
where2 = "c.sis_source_id NOT LIKE '%s-%%'" % sem
|
|
|
|
|
|
|
|
q = """SELECT u.id, u.name, u.sortable_name, string_agg(c.course_code, ','), COUNT(e.id) AS num FROM canvas.enrollments AS e
|
|
JOIN canvas.users AS u ON e.user_id=u.id
|
|
JOIN canvas.courses AS c ON e.course_id=c.id
|
|
WHERE %s
|
|
AND e.workflow_state='active'
|
|
AND e.type='StudentEnrollment'
|
|
AND u.id NOT IN (
|
|
SELECT u.id FROM canvas.enrollments AS e
|
|
JOIN canvas.users AS u ON e.user_id=u.id
|
|
JOIN canvas.courses AS c ON e.course_id=c.id
|
|
WHERE %s
|
|
AND e.workflow_state='active'
|
|
AND e.type='StudentEnrollment'
|
|
GROUP BY u.id
|
|
)
|
|
GROUP BY u.id
|
|
ORDER BY num DESC, u.sortable_name""" % (where1,where2)
|
|
|
|
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
#s = cursor.fetchall() # TODO see below
|
|
#if s:
|
|
for u in cursor:
|
|
users_to_enroll.add(str(u[0]))
|
|
print(u)
|
|
print("%i new users this semester." % len(users_to_enroll))
|
|
print(users_to_enroll)
|
|
return users_to_enroll
|
|
|
|
|
|
# when registrations opens for SUMMER+FALL, get new students from both semesters combined.
|
|
def users_new_this_2x_semester(sem1='', sem2=''):
|
|
if not len(sem1) or (not len(sem2)):
|
|
print("Need 2 semesters")
|
|
return 0
|
|
|
|
where1 = f"(c.sis_source_id LIKE '{sem1}-%%' or c.sis_source_id LIKE '{sem2}-%%')"
|
|
where2 = f"(c.sis_source_id NOT LIKE '{sem1}-%%' and c.sis_source_id NOT LIKE '{sem2}-%%')"
|
|
|
|
q = """SELECT u.id, u.name, u.sortable_name, string_agg(c.course_code, ','), COUNT(e.id) AS num FROM canvas.enrollments AS e
|
|
JOIN canvas.users AS u ON e.user_id=u.id
|
|
JOIN canvas.courses AS c ON e.course_id=c.id
|
|
WHERE %s
|
|
AND e.workflow_state='active'
|
|
AND e.type='StudentEnrollment'
|
|
AND u.id NOT IN (
|
|
SELECT u.id FROM canvas.enrollments AS e
|
|
JOIN canvas.users AS u ON e.user_id=u.id
|
|
JOIN canvas.courses AS c ON e.course_id=c.id
|
|
WHERE %s
|
|
AND e.workflow_state='active'
|
|
AND e.type='StudentEnrollment'
|
|
GROUP BY u.id
|
|
)
|
|
GROUP BY u.id
|
|
ORDER BY num DESC, u.sortable_name""" % (where1,where2)
|
|
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
users_to_enroll = {str(row[0]) for row in cursor.fetchall()}
|
|
print("%i new users this semester." % len(users_to_enroll))
|
|
return users_to_enroll
|
|
|
|
|
|
|
|
|
|
|
|
# Fetch the joined courses (one semester) and schedules tables
|
|
def all_sem_courses_teachers(SEM="202470"):
|
|
q = f"""SELECT c.id, c.name, c.course_code, u.name, u.sortable_name, u.id AS user_cid, p.sis_user_id, s.type, s.crn FROM canvas.courses AS c
|
|
JOIN canvas.enrollments AS e ON e.course_id=c.id
|
|
JOIN canvas.users AS u ON u.id=e.user_id
|
|
JOIN canvas.pseudonyms AS p ON p.user_id=u.id
|
|
JOIN canvas.schedule as s on c.id=s.canvascourse
|
|
WHERE c.sis_source_id LIKE '{SEM}-%'
|
|
AND NOT c.workflow_state='deleted'
|
|
AND e.type='TeacherEnrollment'
|
|
ORDER BY u.sortable_name;"""
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
courses = cursor.fetchall()
|
|
print(courses)
|
|
return courses
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def all_2x_sem_courses_teachers(sem1, sem2):
|
|
q = f"""SELECT c.id, c.name, c.course_code, s.type, s.crn, u.name, u.sortable_name, u.id AS user_cid, p.sis_user_id FROM canvas.courses AS c
|
|
JOIN canvas.enrollments AS e ON e.course_id=c.id
|
|
JOIN canvas.users AS u ON u.id=e.user_id
|
|
JOIN canvas.pseudonyms AS p ON p.user_id=u.id
|
|
JOIN canvas.schedule as s on c.id=s.canvascourse
|
|
WHERE (c.sis_source_id LIKE '{sem1}-%' or c.sis_source_id LIKE '{sem2}-%')
|
|
AND NOT c.workflow_state='deleted'
|
|
AND e.type='TeacherEnrollment'
|
|
ORDER BY u.sortable_name;"""
|
|
(connection,cursor) = db()
|
|
cursor.execute(q)
|
|
courses = cursor.fetchall()
|
|
print(courses)
|
|
return courses
|
|
|
|
|
|
|
|
|
|
|
|
def create_schedule_table_if_not_exists():
|
|
conn,cur = db()
|
|
|
|
# Check if table exists
|
|
cur.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'schedule' and table_schema = 'canvas')")
|
|
table_exists = cur.fetchone()[0]
|
|
|
|
if not table_exists:
|
|
# Create table if it doesn't exist
|
|
cur.execute("""CREATE TABLE canvas.schedule (
|
|
id SERIAL PRIMARY KEY,
|
|
canvascourse INTEGER REFERENCES canvas.courses,
|
|
crn VARCHAR(20),
|
|
code VARCHAR(30),
|
|
units VARCHAR(20),
|
|
teacher TEXT,
|
|
start VARCHAR(30),
|
|
"end" VARCHAR(30),
|
|
type VARCHAR(20),
|
|
loc VARCHAR(80),
|
|
site VARCHAR(50),
|
|
partofday VARCHAR(40),
|
|
cap INTEGER,
|
|
act INTEGER,
|
|
sem VARCHAR(10)
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
cur.close()
|
|
|
|
|
|
# Populate schedule table and correlate to courses table
|
|
def courses_to_sched():
|
|
|
|
# TODO: fix units when they are variable... change to float in between range. round to 0.5 unit.
|
|
|
|
EXECUTE = 1
|
|
last_time = 0
|
|
|
|
seasons = {'10':'wi','30':'sp','50':'su','70':'fa'}
|
|
seasons2 = {'wi':'10', 'sp':'30', 'su':'50', 'fa':'70'}
|
|
|
|
create_schedule_table_if_not_exists()
|
|
conn,cur = db()
|
|
|
|
# get all ilearn courses
|
|
query = """SELECT c.id, c.workflow_state, c.sis_source_id, c.course_code, c.enrollment_term_id, t.name
|
|
FROM canvas.courses c
|
|
JOIN canvas.enrollment_terms t ON c.enrollment_term_id = t.id
|
|
ORDER BY c.sis_source_id, c.course_code;"""
|
|
|
|
cur.execute(query)
|
|
|
|
sis_to_sched = {}
|
|
for row in cur.fetchall():
|
|
sis_source_id = row[2] # c.sis_source_id
|
|
sis_to_sched.setdefault(sis_source_id, []).append(row)
|
|
|
|
|
|
vals_cache = []
|
|
i = 0
|
|
for year in ['16','17','18','19','20','21','22','23','24','25']:
|
|
for sem in ['sp','su','fa']:
|
|
term = f"{sem}{year}"
|
|
sis_code = f"20{year}{seasons2[sem]}"
|
|
print(term)
|
|
sched = 0
|
|
try:
|
|
sched = requests.get(f"http://gavilan.cc/schedule/{term}_sched_expanded.json").json()
|
|
except Exception as e:
|
|
print(e)
|
|
|
|
query = "INSERT INTO canvas.schedule (canvascourse, crn, code, units, teacher, start,\"end\", type, loc, site, partofday, cap, act, sem) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);"
|
|
|
|
if sched:
|
|
for c in sched:
|
|
try:
|
|
pod = ''
|
|
if 'partofday' in c: pod = c['partofday']
|
|
#print(c['cred'])
|
|
cred_match = re.search(r'(\d+\.\d+)\-(\d+\.\d+)',c['cred'])
|
|
if cred_match:
|
|
#print(f"matched range: {cred_match.groups}")
|
|
cred_start = float(cred_match.group(1))
|
|
cred_end = float(cred_match.group(2))
|
|
mid = float(int( (cred_end-cred_start)/2 + cred_start ))
|
|
c['cred'] = str(mid)
|
|
#print(f"middle cred is {c['cred']}")
|
|
|
|
full_sis_code = sis_code+'-'+c['crn']
|
|
|
|
if full_sis_code in sis_to_sched:
|
|
print(c['cred'])
|
|
q = [sis_to_sched[full_sis_code][0][0], c['crn'], c['code'], c['cred'], c['teacher'], c['start'], c['end'], c['type'], c['loc'], c['site'], pod, int(c['cap']), int(c['act']), sis_code]
|
|
vals_cache.append( q ) # [ str(x) for x in q ] )
|
|
#print(f"{i}: {q}")
|
|
i += 1
|
|
if i % 100 == 0:
|
|
if EXECUTE:
|
|
cur.executemany(query, vals_cache)
|
|
conn.commit()
|
|
vals_cache = []
|
|
t = time.process_time()
|
|
delta = t - last_time
|
|
last_time = t
|
|
print(f"Loop {i} - committed to db in %0.3fs. " % delta, flush=True)
|
|
else:
|
|
print(f"{full_sis_code} not in canvas courses.")
|
|
except Exception as e:
|
|
print(e)
|
|
|
|
if EXECUTE:
|
|
cur.executemany(query, vals_cache)
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
|
|
|
|
# Populate schedule table and correlate to courses table
|
|
def refresh_semester_schedule_db(term="sp26"):
|
|
|
|
# TODO: fix units when they are variable... change to float in between range. round to 0.5 unit.
|
|
|
|
EXECUTE = 1
|
|
last_time = 0
|
|
|
|
sis_code = short_to_sis(term)
|
|
|
|
conn,cur = db()
|
|
|
|
drop_query = f"DELETE FROM canvas.schedule WHERE sem='{sis_code}'"
|
|
print(f"executing: {drop_query}")
|
|
cur.execute(drop_query)
|
|
|
|
# get all ilearn courses
|
|
query = """SELECT c.id, c.workflow_state, c.sis_source_id, c.course_code, c.enrollment_term_id, t.name
|
|
FROM canvas.courses c
|
|
JOIN canvas.enrollment_terms t ON c.enrollment_term_id = t.id
|
|
ORDER BY c.sis_source_id, c.course_code;"""
|
|
|
|
|
|
cur.execute(query)
|
|
conn.commit()
|
|
|
|
sis_to_sched = {}
|
|
for row in cur.fetchall():
|
|
sis_source_id = row[2] # c.sis_source_id
|
|
sis_to_sched.setdefault(sis_source_id, []).append(row)
|
|
|
|
|
|
vals_cache = []
|
|
i = 0
|
|
|
|
print(sis_code)
|
|
sched = 0
|
|
try:
|
|
sched = requests.get(f"http://gavilan.cc/schedule/{term}_sched_expanded.json").json()
|
|
except Exception as e:
|
|
print(e)
|
|
|
|
query = "INSERT INTO canvas.schedule (canvascourse, crn, code, units, teacher, start,\"end\", type, loc, site, partofday, cap, act, sem) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);"
|
|
|
|
if sched:
|
|
for c in sched:
|
|
try:
|
|
pod = ''
|
|
if 'partofday' in c: pod = c['partofday']
|
|
#print(c['cred'])
|
|
cred_match = re.search(r'(\d+\.\d+)\-(\d+\.\d+)',c['cred'])
|
|
if cred_match:
|
|
#print(f"matched range: {cred_match.groups}")
|
|
cred_start = float(cred_match.group(1))
|
|
cred_end = float(cred_match.group(2))
|
|
mid = float(int( (cred_end-cred_start)/2 + cred_start ))
|
|
c['cred'] = str(mid)
|
|
#print(f"middle cred is {c['cred']}")
|
|
|
|
full_sis_code = sis_code+'-'+c['crn']
|
|
|
|
if full_sis_code in sis_to_sched:
|
|
shell_id = sis_to_sched[full_sis_code][0][0]
|
|
else:
|
|
print(f"{full_sis_code} not in canvas courses.")
|
|
shell_id = 1
|
|
#print(c['cred'])
|
|
q = [shell_id, c['crn'], c['code'], c['cred'], c['teacher'], c['start'], c['end'], c['type'], c['loc'], c['site'], pod, int(c['cap']), int(c['act']), sis_code]
|
|
print(q)
|
|
vals_cache.append( q ) # [ str(x) for x in q ] )
|
|
#print(f"{i}: {q}")
|
|
i += 1
|
|
if i % 100 == 0:
|
|
if EXECUTE:
|
|
cur.executemany(query, vals_cache)
|
|
conn.commit()
|
|
vals_cache = []
|
|
t = time.process_time()
|
|
delta = t - last_time
|
|
last_time = t
|
|
print(f"Loop {i} - committed to db in %0.3fs. " % delta, flush=True)
|
|
except Exception as e:
|
|
print(e)
|
|
|
|
if EXECUTE:
|
|
cur.executemany(query, vals_cache)
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
|
|
|
|
def student_count(courseid):
|
|
conn,cursor = db()
|
|
q = f"""SELECT COUNT(u.id) AS student_count FROM canvas.courses AS c
|
|
JOIN canvas.enrollments AS e ON e.course_id=c.id
|
|
JOIN canvas.users AS u ON u.id=e.user_id
|
|
WHERE c.id={courseid}
|
|
AND e.type='StudentEnrollment';"""
|
|
cursor.execute(q)
|
|
return cursor.fetchall()[0][0]
|
|
|
|
def teacher_list(courseid):
|
|
conn,cursor = db()
|
|
q = f"""SELECT u.id, u.name FROM canvas.courses AS c
|
|
JOIN canvas.enrollments AS e ON e.course_id=c.id
|
|
JOIN canvas.users AS u ON u.id=e.user_id
|
|
WHERE c.id={courseid}
|
|
AND e.type='TeacherEnrollment';"""
|
|
cursor.execute(q)
|
|
return cursor.fetchall()
|
|
|
|
def everyone_teacher_role():
|
|
conn,cursor = db()
|
|
q = '''select distinct ON (u.name) u.name, u.id, p.sis_user_id, u.created_at, c.course_code from canvas.enrollments e
|
|
join canvas.users u on u.id=e.user_id
|
|
join canvas.courses c on e.course_id=c.id
|
|
join canvas.pseudonyms p on u.id=p.user_id
|
|
where e.type='TeacherEnrollment'
|
|
order by u.name;'''
|
|
cursor.execute(q)
|
|
return cursor.fetchall()
|
|
|
|
|
|
def iLearn_name_from_goo(goo):
|
|
goo = goo.upper()
|
|
conn,cursor = db()
|
|
q = f"select u.id, u.name, u.sortable_name, p.sis_user_id from canvas.pseudonyms p join canvas.users u on u.id=p.user_id where p.sis_user_id='{goo}';"
|
|
cursor.execute(q)
|
|
return cursor.fetchone()
|
|
|
|
|
|
# -------------------- Useful Info (summaries, events, tags) --------------------
|
|
|
|
def init_usefulinfo_schema():
|
|
"""Create tables for summaries, events, tags, and link tables if missing."""
|
|
CON, CUR = db()
|
|
try:
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_summary (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
summary_hash CHAR(64) UNIQUE,
|
|
source TEXT,
|
|
date_label TEXT,
|
|
short_text TEXT,
|
|
summary_text TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_event (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
dt TEXT,
|
|
length TEXT,
|
|
title TEXT,
|
|
description TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_summary_event (
|
|
summary_id BIGINT NOT NULL REFERENCES useful_info_summary(id) ON DELETE CASCADE,
|
|
event_id BIGINT NOT NULL REFERENCES useful_info_event(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (summary_id, event_id)
|
|
);
|
|
"""
|
|
)
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_tag (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
name TEXT UNIQUE
|
|
);
|
|
"""
|
|
)
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_summary_tag (
|
|
summary_id BIGINT NOT NULL REFERENCES useful_info_summary(id) ON DELETE CASCADE,
|
|
tag_id BIGINT NOT NULL REFERENCES useful_info_tag(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (summary_id, tag_id)
|
|
);
|
|
"""
|
|
)
|
|
#-- Original Emails and Attachments
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_email (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
summary_id BIGINT NULL REFERENCES useful_info_summary(id) ON DELETE SET NULL,
|
|
entry_id TEXT UNIQUE,
|
|
subject_raw TEXT,
|
|
subject_norm TEXT,
|
|
sender TEXT,
|
|
sent_iso TEXT,
|
|
body TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_attachment (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
path TEXT UNIQUE,
|
|
text TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
CUR.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS useful_info_email_attachment (
|
|
email_id BIGINT NOT NULL REFERENCES useful_info_email(id) ON DELETE CASCADE,
|
|
attachment_id BIGINT NOT NULL REFERENCES useful_info_attachment(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (email_id, attachment_id)
|
|
);
|
|
"""
|
|
)
|
|
CON.commit()
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
|
|
|
|
def _sha256(s):
|
|
return hashlib.sha256(s.encode('utf-8','ignore')).hexdigest()
|
|
|
|
|
|
def _get_or_create_tag_id(CUR, name):
|
|
try:
|
|
CUR.execute("INSERT INTO useful_info_tag (name) VALUES (%s) ON CONFLICT (name) DO NOTHING RETURNING id", (name,))
|
|
row = CUR.fetchone()
|
|
if row and row[0]:
|
|
return row[0]
|
|
except Exception:
|
|
pass
|
|
CUR.execute("SELECT id FROM useful_info_tag WHERE name=%s", (name,))
|
|
row = CUR.fetchone()
|
|
return row[0] if row else None
|
|
|
|
|
|
def insert_usefulinfo_record(parsed):
|
|
"""
|
|
Insert a summarize_u_info() JSON result into Postgres.
|
|
Expected keys: source, date, tags (list), short, summary, events (list of {dt,length,title,description}).
|
|
Dedups summaries using a stable hash; links tags and events via link tables.
|
|
Returns summary_id.
|
|
"""
|
|
if not isinstance(parsed, dict):
|
|
return None
|
|
source = parsed.get('source')
|
|
date_label = parsed.get('date')
|
|
short_text = parsed.get('short') or ''
|
|
summary_text = parsed.get('summary') or ''
|
|
# Normalize tags into a flat list of strings
|
|
def _norm_tags(t):
|
|
if t is None:
|
|
return []
|
|
if isinstance(t, list):
|
|
out = []
|
|
for x in t:
|
|
if isinstance(x, (list, tuple)):
|
|
out.extend([str(y) for y in x if y is not None])
|
|
elif isinstance(x, dict):
|
|
# accept {name: "..."}
|
|
name = x.get('name') if 'name' in x else None
|
|
if name:
|
|
out.append(str(name))
|
|
elif x is not None:
|
|
out.append(str(x))
|
|
return out
|
|
if isinstance(t, str):
|
|
# try json first
|
|
try:
|
|
j = json.loads(t)
|
|
return _norm_tags(j)
|
|
except Exception:
|
|
pass
|
|
# split by comma
|
|
return [s.strip() for s in t.split(',') if s.strip()]
|
|
return [str(t)]
|
|
|
|
# Normalize events into a list of dicts with expected keys
|
|
def _norm_events(ev):
|
|
def _one(e):
|
|
if not isinstance(e, dict):
|
|
return None
|
|
dt = e.get('dt') or e.get('datetime') or e.get('date') or e.get('when')
|
|
length = e.get('length') or e.get('duration') or e.get('dur')
|
|
title = e.get('title') or e.get('name') or e.get('summary')
|
|
desc = e.get('description') or e.get('details') or e.get('note') or e.get('body')
|
|
return {'dt': dt, 'length': length, 'title': title, 'description': desc}
|
|
|
|
if ev is None:
|
|
return []
|
|
if isinstance(ev, list):
|
|
out = []
|
|
for x in ev:
|
|
if isinstance(x, dict):
|
|
y = _one(x)
|
|
if y:
|
|
out.append(y)
|
|
elif isinstance(x, str):
|
|
try:
|
|
j = json.loads(x)
|
|
if isinstance(j, dict):
|
|
y = _one(j)
|
|
if y:
|
|
out.append(y)
|
|
elif isinstance(j, list):
|
|
out.extend(_norm_events(j))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
if isinstance(ev, dict):
|
|
y = _one(ev)
|
|
return [y] if y else []
|
|
if isinstance(ev, str):
|
|
try:
|
|
j = json.loads(ev)
|
|
return _norm_events(j)
|
|
except Exception:
|
|
return []
|
|
return []
|
|
|
|
tags = _norm_tags(parsed.get('tags'))
|
|
events = _norm_events(parsed.get('events'))
|
|
|
|
s_hash = _sha256((source or '') + "\n" + (date_label or '') + "\n" + short_text + "\n" + summary_text)
|
|
|
|
CON, CUR = db()
|
|
summary_id = None
|
|
try:
|
|
CUR.execute(
|
|
"""
|
|
INSERT INTO useful_info_summary
|
|
(summary_hash, source, date_label, short_text, summary_text)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
ON CONFLICT (summary_hash)
|
|
DO UPDATE SET short_text=EXCLUDED.short_text, summary_text=EXCLUDED.summary_text
|
|
RETURNING id
|
|
""",
|
|
(s_hash, source, date_label, short_text, summary_text)
|
|
)
|
|
row = CUR.fetchone()
|
|
summary_id = row[0] if row else None
|
|
|
|
# Tags
|
|
if summary_id and isinstance(tags, list):
|
|
for t in tags:
|
|
if not t:
|
|
continue
|
|
tag_id = _get_or_create_tag_id(CUR, str(t))
|
|
if tag_id:
|
|
try:
|
|
CUR.execute("SAVEPOINT sp_tag")
|
|
CUR.execute(
|
|
"INSERT INTO useful_info_summary_tag (summary_id, tag_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
|
(summary_id, tag_id)
|
|
)
|
|
CUR.execute("RELEASE SAVEPOINT sp_tag")
|
|
except Exception as ex_tag_link:
|
|
try:
|
|
CUR.execute("ROLLBACK TO SAVEPOINT sp_tag")
|
|
except Exception:
|
|
pass
|
|
print("[usefulinfo][tag-link-failed] summary_id=", summary_id, " tag=", t, " error=", str(ex_tag_link))
|
|
|
|
# Events (standardized: insert into event table, then link in summary_event)
|
|
if summary_id and isinstance(events, list):
|
|
for idx, e in enumerate(events, start=1):
|
|
# Diagnostics: validate dt and length
|
|
raw_dt = (e or {}).get('dt')
|
|
raw_len = (e or {}).get('length')
|
|
raw_title = (e or {}).get('title')
|
|
raw_desc = (e or {}).get('description')
|
|
|
|
dt_str = str(raw_dt) if raw_dt is not None else ''
|
|
parsed_ok = False
|
|
parsed_iso = ''
|
|
all_day = False
|
|
try:
|
|
if dt_str:
|
|
# all-day if no explicit time markers
|
|
all_day = not (re.search(r"\d\d?:\d\d", dt_str) or re.search(r"\b(am|pm)\b", dt_str, re.I))
|
|
p = parse(dt_str)
|
|
parsed_ok = True
|
|
parsed_iso = p.isoformat()
|
|
except Exception as ex_dt:
|
|
print("[usefulinfo][event-parse] bad dt:", dt_str, "error:", str(ex_dt))
|
|
|
|
# Normalize DT string for storage
|
|
normalized_dt = dt_str
|
|
try:
|
|
if parsed_ok:
|
|
if all_day:
|
|
normalized_dt = p.date().isoformat() # YYYY-MM-DD
|
|
else:
|
|
normalized_dt = p.strftime('%Y-%m-%dT%H:%M:%S') # ISO without TZ
|
|
except Exception as ex_norm:
|
|
print("[usefulinfo][event-dt-normalize-failed] dt_str=", dt_str, " error=", str(ex_norm))
|
|
|
|
def _mins(s):
|
|
if not s:
|
|
return 60
|
|
try:
|
|
n = int(s)
|
|
if n <= 12:
|
|
return n * 60
|
|
return n
|
|
except Exception:
|
|
pass
|
|
m = re.findall(r"(\d+(?:\.\d+)?)\s*([hm])", str(s), flags=re.I)
|
|
total = 0
|
|
for num, unit in m:
|
|
try:
|
|
val = float(num)
|
|
total += int(val * 60) if unit.lower() == 'h' else int(val)
|
|
except Exception:
|
|
pass
|
|
return total or 60
|
|
|
|
computed_minutes = _mins(raw_len)
|
|
|
|
try:
|
|
CUR.execute("SAVEPOINT sp_event")
|
|
CUR.execute(
|
|
"""
|
|
INSERT INTO useful_info_event (dt, length, title, description)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(normalized_dt, raw_len, raw_title, raw_desc)
|
|
)
|
|
evrow = CUR.fetchone()
|
|
if evrow and evrow[0]:
|
|
try:
|
|
CUR.execute("SAVEPOINT sp_evlink")
|
|
CUR.execute(
|
|
"INSERT INTO useful_info_summary_event (summary_id, event_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
|
(summary_id, evrow[0])
|
|
)
|
|
CUR.execute("RELEASE SAVEPOINT sp_evlink")
|
|
except Exception as ex_evlink:
|
|
try:
|
|
CUR.execute("ROLLBACK TO SAVEPOINT sp_evlink")
|
|
except Exception:
|
|
pass
|
|
print("[usefulinfo][event-link-failed] summary_id=", summary_id, " event_id=", evrow[0], " error=", str(ex_evlink))
|
|
print(f"[usefulinfo] inserted event id={evrow[0]} (summary_id={summary_id}) dt='{dt_str}' parsed={parsed_ok} iso='{parsed_iso}' all_day={all_day} minutes={computed_minutes} title='{raw_title}'")
|
|
else:
|
|
print(f"[usefulinfo][warn] no event id returned for dt='{dt_str}' title='{raw_title}'")
|
|
CUR.execute("RELEASE SAVEPOINT sp_event")
|
|
except Exception as ex_ins:
|
|
try:
|
|
CUR.execute("ROLLBACK TO SAVEPOINT sp_event")
|
|
except Exception:
|
|
pass
|
|
print("[usefulinfo][event-insert-failed] summary_id=", summary_id,
|
|
" event=", e,
|
|
" dt_str=", dt_str,
|
|
" parsed_ok=", parsed_ok,
|
|
" parsed_iso=", parsed_iso,
|
|
" all_day=", all_day,
|
|
" computed_minutes=", computed_minutes,
|
|
" error=", str(ex_ins))
|
|
|
|
CON.commit()
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
return summary_id
|
|
|
|
|
|
def _decode_safelinks_text(text):
|
|
"""Replace Outlook SafeLinks with their original URL inside given text.
|
|
Returns modified text (or original if no changes).
|
|
"""
|
|
if not text:
|
|
return text
|
|
try:
|
|
import urllib.parse as _up
|
|
import re as _re
|
|
def _decode_match(m):
|
|
u = m.group(0)
|
|
try:
|
|
q = _up.urlparse(u).query
|
|
params = _up.parse_qs(q)
|
|
real = params.get('url', [''])[0] or params.get('target', [''])[0]
|
|
return _up.unquote(real) if real else u
|
|
except Exception:
|
|
return u
|
|
return _re.sub(r"https?://[\w\.-]*safelinks\.protection\.outlook\.com/[^\s\)\]\>]+", _decode_match, text)
|
|
except Exception:
|
|
return text
|
|
|
|
|
|
def fix_safelinks_in_db(batch_size=500):
|
|
"""Decode SafeLinks URLs in existing DB rows for:
|
|
- useful_info_email.body
|
|
- useful_info_summary.summary_text
|
|
- useful_info_attachment.text
|
|
Returns a dict of counts updated.
|
|
"""
|
|
CON, CUR = db()
|
|
updated = {'email_body': 0, 'summary_text': 0, 'attachment_text': 0}
|
|
try:
|
|
# Emails
|
|
CUR.execute("SELECT id, body FROM useful_info_email")
|
|
rows = CUR.fetchall()
|
|
for rid, body in rows:
|
|
new = _decode_safelinks_text(body)
|
|
if new != body:
|
|
CUR.execute("UPDATE useful_info_email SET body=%s WHERE id=%s", (new, rid))
|
|
updated['email_body'] += 1
|
|
CON.commit()
|
|
|
|
# Summaries
|
|
CUR.execute("SELECT id, summary_text FROM useful_info_summary")
|
|
rows = CUR.fetchall()
|
|
for rid, st in rows:
|
|
new = _decode_safelinks_text(st)
|
|
if new != st:
|
|
CUR.execute("UPDATE useful_info_summary SET summary_text=%s WHERE id=%s", (new, rid))
|
|
updated['summary_text'] += 1
|
|
CON.commit()
|
|
|
|
# Attachments text
|
|
CUR.execute("SELECT id, text FROM useful_info_attachment WHERE text IS NOT NULL")
|
|
rows = CUR.fetchall()
|
|
for rid, tx in rows:
|
|
new = _decode_safelinks_text(tx)
|
|
if new != tx:
|
|
CUR.execute("UPDATE useful_info_attachment SET text=%s WHERE id=%s", (new, rid))
|
|
updated['attachment_text'] += 1
|
|
CON.commit()
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
print('[usefulinfo][fix_safelinks] updated:', updated)
|
|
return updated
|
|
|
|
def insert_usefulinfo_email(subject_raw, subject_norm, sender, sent_iso, body, attachments=None, summary_id=None, entry_id=None):
|
|
"""Insert an original email and any attachments.
|
|
attachments: list of dicts like {'path': str, 'text': str or None}
|
|
summary_id: optional FK to useful_info_summary; can be None and linked later.
|
|
Returns email_id.
|
|
"""
|
|
attachments = attachments or []
|
|
CON, CUR = db()
|
|
email_id = None
|
|
try:
|
|
try:
|
|
CUR.execute(
|
|
"""
|
|
INSERT INTO useful_info_email (summary_id, entry_id, subject_raw, subject_norm, sender, sent_iso, body)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (entry_id) DO NOTHING
|
|
RETURNING id
|
|
""",
|
|
(summary_id, entry_id, subject_raw, subject_norm, sender, sent_iso, body)
|
|
)
|
|
except Exception:
|
|
# Fallback if entry_id column not present
|
|
CUR.execute(
|
|
"""
|
|
INSERT INTO useful_info_email (summary_id, subject_raw, subject_norm, sender, sent_iso, body)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(summary_id, subject_raw, subject_norm, sender, sent_iso, body)
|
|
)
|
|
row = CUR.fetchone()
|
|
email_id = row[0] if row else None
|
|
|
|
for att in attachments:
|
|
path = (att or {}).get('path')
|
|
text = (att or {}).get('text')
|
|
if not path:
|
|
continue
|
|
try:
|
|
CUR.execute("SAVEPOINT sp_att")
|
|
CUR.execute(
|
|
"""
|
|
INSERT INTO useful_info_attachment (path, text)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT (path) DO UPDATE SET text = COALESCE(EXCLUDED.text, useful_info_attachment.text)
|
|
RETURNING id
|
|
""",
|
|
(path, text)
|
|
)
|
|
arow = CUR.fetchone()
|
|
att_id = arow[0] if arow else None
|
|
if email_id and att_id:
|
|
CUR.execute(
|
|
"INSERT INTO useful_info_email_attachment (email_id, attachment_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
|
(email_id, att_id)
|
|
)
|
|
CUR.execute("RELEASE SAVEPOINT sp_att")
|
|
except Exception as ex_a:
|
|
try:
|
|
CUR.execute("ROLLBACK TO SAVEPOINT sp_att")
|
|
except Exception:
|
|
pass
|
|
print('[usefulinfo][attachment-insert-failed]', path, str(ex_a))
|
|
|
|
CON.commit()
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
return email_id
|
|
|
|
|
|
def upgrade_usefulinfo_schema():
|
|
"""Ensure incremental-friendly schema (entry_id unique) is present."""
|
|
CON, CUR = db()
|
|
try:
|
|
try:
|
|
CUR.execute("ALTER TABLE useful_info_email ADD COLUMN IF NOT EXISTS entry_id TEXT")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
CUR.execute("CREATE UNIQUE INDEX IF NOT EXISTS useful_info_email_entry_id_idx ON useful_info_email(entry_id)")
|
|
except Exception:
|
|
pass
|
|
CON.commit()
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
|
|
|
|
def link_emails_to_summary(subject_norm, summary_id):
|
|
"""Link any emails with the given normalized subject to the provided summary.
|
|
Returns number of rows updated.
|
|
"""
|
|
CON, CUR = db()
|
|
try:
|
|
CUR.execute(
|
|
"UPDATE useful_info_email SET summary_id=%s WHERE summary_id IS NULL AND subject_norm=%s",
|
|
(summary_id, subject_norm)
|
|
)
|
|
n = CUR.rowcount
|
|
CON.commit()
|
|
return n
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
|
|
|
|
## Migration/diagnostic helpers removed per request. Assume a clean schema created by init_usefulinfo_schema.
|
|
|
|
|
|
def export_usefulinfo_events_to_ics(filepath='cache/useful_info_events.ics'):
|
|
"""Export events from useful info tables to an .ics file.
|
|
- Attempts to parse dt and length into DTSTART/DTEND.
|
|
- Includes title (SUMMARY), description (DESCRIPTION), and tags (CATEGORIES).
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
from dateutil import parser as dtparser
|
|
|
|
CON, CUR = db()
|
|
try:
|
|
# Pull events with linked summary and aggregated tags
|
|
CUR.execute(
|
|
"""
|
|
SELECT e.id, e.dt, e.length, e.title, e.description,
|
|
s.source, s.date_label, s.short_text,
|
|
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
|
|
FROM useful_info_event e
|
|
JOIN useful_info_summary_event se ON se.event_id = e.id
|
|
JOIN useful_info_summary s ON s.id = se.summary_id
|
|
LEFT JOIN useful_info_summary_tag st ON st.summary_id = s.id
|
|
LEFT JOIN useful_info_tag t ON t.id = st.tag_id
|
|
GROUP BY e.id, s.source, s.date_label, s.short_text
|
|
ORDER BY e.id
|
|
"""
|
|
)
|
|
rows = CUR.fetchall()
|
|
finally:
|
|
CUR.close(); CON.close()
|
|
|
|
def _parse_minutes(length_str):
|
|
if not length_str:
|
|
return 60
|
|
try:
|
|
n = int(length_str)
|
|
if n <= 12:
|
|
return n * 60
|
|
return n
|
|
except Exception:
|
|
pass
|
|
m = re.findall(r"(\d+(?:\.\d+)?)\s*([hm])", length_str, flags=re.I)
|
|
minutes = 0
|
|
if m:
|
|
for num, unit in m:
|
|
try:
|
|
val = float(num)
|
|
if unit.lower() == 'h':
|
|
minutes += int(val * 60)
|
|
else:
|
|
minutes += int(val)
|
|
except Exception:
|
|
pass
|
|
if minutes > 0:
|
|
return minutes
|
|
return 60
|
|
|
|
def _has_time_component(s):
|
|
if not s:
|
|
return False
|
|
if re.search(r"\d\d?:\d\d", s):
|
|
return True
|
|
if re.search(r"\b(am|pm)\b", s, re.I):
|
|
return True
|
|
return False
|
|
|
|
def _format_dt(dtobj):
|
|
# Local time (floating) format
|
|
return dtobj.strftime('%Y%m%dT%H%M%S')
|
|
|
|
now_utc = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
|
|
|
lines = []
|
|
lines.append('BEGIN:VCALENDAR')
|
|
lines.append('VERSION:2.0')
|
|
lines.append('PRODID:-//canvasapp//Useful Info Events//EN')
|
|
lines.append('CALSCALE:GREGORIAN')
|
|
lines.append('METHOD:PUBLISH')
|
|
lines.append('X-WR-CALNAME:Useful Info')
|
|
|
|
for r in rows:
|
|
ev_id = r[0]
|
|
dt_str = r[1]
|
|
length_str = r[2]
|
|
title = r[3] or ''
|
|
desc = r[4] or ''
|
|
source = r[5] or ''
|
|
date_label = r[6] or ''
|
|
short_text = r[7] or ''
|
|
tags = r[8] or []
|
|
|
|
# Try to parse DTSTART/DTEND
|
|
all_day = not _has_time_component(str(dt_str))
|
|
dtstart = None
|
|
dtend = None
|
|
try:
|
|
if dt_str:
|
|
parsed = dtparser.parse(str(dt_str), fuzzy=True)
|
|
if all_day:
|
|
# All-day event
|
|
dtstart = parsed.date()
|
|
dtend = (parsed.date() + timedelta(days=1))
|
|
else:
|
|
dtstart = parsed
|
|
minutes = _parse_minutes(str(length_str))
|
|
dtend = parsed + timedelta(minutes=minutes)
|
|
except Exception:
|
|
# If we cannot parse date, skip this event
|
|
continue
|
|
|
|
lines.append('BEGIN:VEVENT')
|
|
lines.append('UID:usefulinfo-event-%s@gavilan' % ev_id)
|
|
lines.append('DTSTAMP:%s' % now_utc)
|
|
|
|
if all_day and dtstart and dtend:
|
|
lines.append('DTSTART;VALUE=DATE:%s' % dtstart.strftime('%Y%m%d'))
|
|
lines.append('DTEND;VALUE=DATE:%s' % dtend.strftime('%Y%m%d'))
|
|
elif dtstart and dtend:
|
|
lines.append('DTSTART:%s' % _format_dt(dtstart))
|
|
lines.append('DTEND:%s' % _format_dt(dtend))
|
|
|
|
if title:
|
|
lines.append('SUMMARY:%s' % title.replace('\n', ' ').replace('\r', ' '))
|
|
|
|
full_desc = desc
|
|
extra = []
|
|
if short_text:
|
|
extra.append('Context: ' + short_text)
|
|
if source or date_label:
|
|
extra.append('Source: %s Date label: %s' % (source, date_label))
|
|
if extra:
|
|
if full_desc:
|
|
full_desc += '\n\n' + '\n'.join(extra)
|
|
else:
|
|
full_desc = '\n'.join(extra)
|
|
if full_desc:
|
|
# Basic escaping of commas/semicolons per RFC is often needed; we keep it simple here
|
|
lines.append('DESCRIPTION:%s' % full_desc.replace('\r', ' ').replace('\n', '\\n'))
|
|
|
|
if tags:
|
|
try:
|
|
cats = ','.join([t for t in tags if t])
|
|
if cats:
|
|
lines.append('CATEGORIES:%s' % cats)
|
|
except Exception:
|
|
pass
|
|
|
|
lines.append('END:VEVENT')
|
|
|
|
lines.append('END:VCALENDAR')
|
|
|
|
# Write file
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
f.write("\r\n".join(lines) + "\r\n")
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
print ('')
|
|
options = {
|
|
1: ['all teachers', teachers_by_term],
|
|
2: ['courses in term', courses_in_term],
|
|
3: ['pages in term', pages_in_term],
|
|
4: ['new students this semester', users_new_this_semester],
|
|
5: ['all semester courses + teachers', all_sem_courses_teachers],
|
|
6: ['Populate schedule table and correlate to courses table', courses_to_sched],
|
|
7: ['refresh db schedule 1 semester', refresh_semester_schedule_db],
|
|
}
|
|
|
|
|
|
if len(sys.argv) > 1 and re.search(r'^\d+',sys.argv[1]):
|
|
resp = int(sys.argv[1])
|
|
print("\n\nPerforming: %s\n\n" % options[resp][0])
|
|
|
|
else:
|
|
print ('')
|
|
for key in options:
|
|
print(str(key) + '.\t' + options[key][0])
|
|
|
|
print('')
|
|
resp = input('Choose: ')
|
|
|
|
# Call the function in the options dict
|
|
options[ int(resp)][1]()
|